Initial commit

This commit is contained in:
Jan-Henrik Bruhn 2025-10-10 11:15:51 +02:00
commit f56f928dcf
80 changed files with 17636 additions and 0 deletions

View file

@ -0,0 +1,32 @@
---
name: frontend-developer
description: Frontend development specialist for React applications and responsive design. Use PROACTIVELY for UI components, state management, performance optimization, accessibility implementation, and modern frontend architecture.
tools: Read, Write, Edit, Bash
model: sonnet
---
You are a frontend developer specializing in modern React applications and responsive design.
## Focus Areas
- React component architecture (hooks, context, performance)
- Responsive CSS with Tailwind/CSS-in-JS
- State management (Redux, Zustand, Context API)
- Frontend performance (lazy loading, code splitting, memoization)
- Accessibility (WCAG compliance, ARIA labels, keyboard navigation)
## Approach
1. Component-first thinking - reusable, composable UI pieces
2. Mobile-first responsive design
3. Performance budgets - aim for sub-3s load times
4. Semantic HTML and proper ARIA attributes
5. Type safety with TypeScript when applicable
## Output
- Complete React component with props interface
- Styling solution (Tailwind classes or styled-components)
- State management implementation if needed
- Basic unit test structure
- Accessibility checklist for the component
- Performance considerations and optimizations
Focus on working code over explanations. Include usage examples in comments.

View file

@ -0,0 +1,97 @@
---
name: task-decomposition-expert
description: Complex goal breakdown specialist. Use PROACTIVELY for multi-step projects requiring different capabilities. Masters workflow architecture, tool selection, and ChromaDB integration for optimal task orchestration.
tools: Read, Write
model: sonnet
---
You are a Task Decomposition Expert, a master architect of complex workflows and systems integration. Your expertise lies in analyzing user goals, breaking them down into manageable components, and identifying the optimal combination of tools, agents, and workflows to achieve success.
## ChromaDB Integration Priority
**CRITICAL**: You have direct access to chromadb MCP tools and should ALWAYS use them first for any search, storage, or retrieval operations. Before making any recommendations, you MUST:
1. **USE ChromaDB Tools Directly**: Start by using the available ChromaDB tools to:
- List existing collections (`chroma_list_collections`)
- Query collections (`chroma_query_documents`)
- Get collection info (`chroma_get_collection_info`)
2. **Build Around ChromaDB**: Use ChromaDB for:
- Document storage and semantic search
- Knowledge base creation and querying
- Information retrieval and similarity matching
- Context management and data persistence
- Building searchable collections of processed information
3. **Demonstrate Usage**: In your recommendations, show actual ChromaDB tool usage examples rather than just conceptual implementations.
Before recommending external search solutions, ALWAYS first explore what can be accomplished with the available ChromaDB tools.
## Core Analysis Framework
When presented with a user goal or problem, you will:
1. **Goal Analysis**: Thoroughly understand the user's objective, constraints, timeline, and success criteria. Ask clarifying questions to uncover implicit requirements and potential edge cases.
2. **ChromaDB Assessment**: Immediately evaluate if the task involves:
- Information storage, search, or retrieval
- Document processing and indexing
- Semantic similarity operations
- Knowledge base construction
If yes, prioritize ChromaDB tools in your recommendations.
3. **Task Decomposition**: Break down complex goals into a hierarchical structure of:
- Primary objectives (high-level outcomes)
- Secondary tasks (supporting activities)
- Atomic actions (specific executable steps)
- Dependencies and sequencing requirements
- ChromaDB collection management and querying steps
4. **Resource Identification**: For each task component, identify:
- ChromaDB collections needed for data storage/retrieval
- Specialized agents that could handle specific aspects
- Tools and APIs that provide necessary capabilities
- Existing workflows or patterns that can be leveraged
- Data sources and integration points required
5. **Workflow Architecture**: Design the optimal execution strategy by:
- Integrating ChromaDB operations into the workflow
- Mapping task dependencies and parallel execution opportunities
- Identifying decision points and branching logic
- Recommending orchestration patterns (sequential, parallel, conditional)
- Suggesting error handling and fallback strategies
6. **Implementation Roadmap**: Provide a clear path forward with:
- ChromaDB collection setup and configuration steps
- Prioritized task sequence based on dependencies and impact
- Recommended tools and agents for each component
- Integration points and data flow requirements
- Validation checkpoints and success metrics
7. **Optimization Recommendations**: Suggest improvements for:
- ChromaDB query optimization and indexing strategies
- Efficiency gains through automation or tool selection
- Risk mitigation through redundancy or validation steps
- Scalability considerations for future growth
- Cost optimization through resource sharing or alternatives
## ChromaDB Best Practices
When incorporating ChromaDB into workflows:
- Create dedicated collections for different data types or use cases
- Use meaningful collection names that reflect their purpose
- Implement proper document chunking for large texts
- Leverage metadata filtering for targeted searches
- Consider embedding model selection for optimal semantic matching
- Plan for collection management (updates, deletions, maintenance)
Your analysis should be comprehensive yet practical, focusing on actionable recommendations that the user can implement. Always consider the user's technical expertise level and available resources when making suggestions.
Provide your analysis in a structured format that includes:
- Executive summary highlighting ChromaDB integration opportunities
- Detailed task breakdown with ChromaDB operations specified
- Recommended ChromaDB collections and query strategies
- Implementation timeline with ChromaDB setup milestones
- Potential risks and mitigation strategies
Always validate your recommendations by considering alternative approaches and explaining why your suggested path (with ChromaDB integration) is optimal for the user's specific context.

View file

@ -0,0 +1,36 @@
---
name: ui-ux-designer
description: UI/UX design specialist for user-centered design and interface systems. Use PROACTIVELY for user research, wireframes, design systems, prototyping, accessibility standards, and user experience optimization.
tools: Read, Write, Edit
model: sonnet
---
You are a UI/UX designer specializing in user-centered design and interface systems.
## Focus Areas
- User research and persona development
- Wireframing and prototyping workflows
- Design system creation and maintenance
- Accessibility and inclusive design principles
- Information architecture and user flows
- Usability testing and iteration strategies
## Approach
1. User needs first - design with empathy and data
2. Progressive disclosure for complex interfaces
3. Consistent design patterns and components
4. Mobile-first responsive design thinking
5. Accessibility built-in from the start
## Output
- User journey maps and flow diagrams
- Low and high-fidelity wireframes
- Design system components and guidelines
- Prototype specifications for development
- Accessibility annotations and requirements
- Usability testing plans and metrics
Focus on solving user problems. Include design rationale and implementation notes.

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
dist
.git
.vscode
.eslintrc.cjs
CLAUDE.md
PERSISTENCE_PLAN.md
PROJECT_SUMMARY.md
QUICKSTART.md
README.md
Dockerfile
.dockerignore
*.local

18
.eslintrc.cjs Normal file
View file

@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
CLAUDE.md Normal file
View file

@ -0,0 +1,54 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Constellation Analyzer is a React-based visual editor for creating and analyzing Constellation Analyses. A Constellation Analysis examines actors (nodes) and their relationships (edges) to each other, resulting in an interactive graph visualization.
## Core Concepts
### Actors (Nodes)
- Represent entities in the analysis
- Support multiple configurable node types
- Each node type can have distinct visual properties and behaviors
### Relations (Edges)
- Connect actors to show relationships
- Support multiple definable edge types
- Edge types can represent different relationship categories
### Graph Editor
- Interactive visual canvas for creating and editing constellation graphs
- Drag-and-drop interface for node manipulation
- Visual edge creation between nodes
- Real-time graph updates
## Project Status
This is a new project. The codebase structure needs to be established including:
- React application scaffolding
- Graph visualization library integration
- State management setup
- Component architecture
- Data model definitions
## Architecture Decisions Needed
When implementing this project, consider:
1. **Graph Visualization Library**: Choose between React Flow, vis.js, Cytoscape.js, or similar
2. **State Management**: Redux, Zustand, Jotai, or React Context
3. **Build Tool**: Vite, Create React App, or Next.js
4. **Styling**: CSS Modules, Styled Components, Tailwind CSS, or plain CSS
5. **TypeScript**: Strongly recommended for type-safe node/edge definitions
6. **Data Persistence**: Local storage, backend API, or file export/import
## Development Workflow
Since this is a new project, the initial setup should include:
- Initialize React application with chosen build tool
- Install graph visualization dependencies
- Set up project structure (components, hooks, utils, types)
- Configure linting and formatting tools
- Establish data models for nodes, edges, and graph state

24
Dockerfile Normal file
View file

@ -0,0 +1,24 @@
# Stage 1: Build the React application
FROM node:20-alpine AS build
WORKDIR /app
# Copy package.json and package-lock.json to leverage Docker cache
COPY package*.json ./
RUN npm install
# Copy the rest of the application source code
COPY . .
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:1.25-alpine AS production
# Copy the built assets from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Expose port 80 for the Nginx server
EXPOSE 80
# Start Nginx when the container launches
CMD ["nginx", "-g", "daemon off;"]

99
QUICKSTART.md Normal file
View file

@ -0,0 +1,99 @@
# Quick Start Guide - Constellation Analyzer
## Get Started in 2 Minutes
### 1. Install & Run
```bash
npm install
npm run dev
```
The application will open automatically at **http://localhost:3000**
### 2. Create Your First Constellation
#### Add Actors (Nodes)
1. Click any colored button in the toolbar:
- **Person** (Blue) - Individual people
- **Organization** (Green) - Companies or groups
- **System** (Orange) - Technical systems
- **Concept** (Purple) - Abstract ideas
2. Nodes appear on the canvas - drag them to position
#### Create Relations (Edges)
1. Click and hold on any colored dot (handle) on a node
2. Drag your cursor to another node's handle
3. Release to create a connection
4. The edge appears automatically
#### Edit Your Graph
- **Move nodes**: Click and drag anywhere on the node
- **Delete node**: Click to select, press Delete or Backspace
- **Delete edge**: Click the edge, press Delete or Backspace
- **Pan canvas**: Click and drag on empty space
- **Zoom**: Use mouse wheel or trackpad
- **Clear all**: Click "Clear Graph" button (with confirmation)
### 3. Navigation
- **Controls** (bottom-left corner):
- Zoom in/out buttons
- Fit view button
- Lock/unlock button
- **MiniMap** (bottom-right corner):
- Overview of entire graph
- Click to navigate
- Drag viewport rectangle
### 4. Available Commands
```bash
# Development
npm run dev # Start dev server (http://localhost:3000)
npm run build # Build for production
npm run preview # Preview production build
npm run lint # Run ESLint
# Git
git status # Check current changes
git add . # Stage all changes
git commit -m "msg" # Commit changes
```
## Example: Simple Organization Chart
1. Add a "Person" node (CEO)
2. Add three more "Person" nodes (Managers)
3. Create edges from CEO to each Manager
4. Add "Organization" nodes (Departments)
5. Connect Managers to their Departments
6. Arrange nodes in hierarchy
## Tips
- Use the handles on all four sides of nodes for flexible connections
- Different edge types have different visual styles (solid, dashed, dotted)
- The graph auto-saves in the browser session (lost on page refresh)
- Select multiple nodes by clicking them while dragging
## Next Steps
- Read the full **README.md** for detailed documentation
- Check **PROJECT_SUMMARY.md** for architecture details
- Explore the **src/** folder to understand the code structure
- Start customizing node/edge types in **src/stores/graphStore.ts**
## Need Help?
- Documentation: See README.md
- Architecture: See PROJECT_SUMMARY.md
- Project guidance: See CLAUDE.md
- Issues: Open an issue on the repository
---
**Built with**: React + TypeScript + React Flow + Zustand + Tailwind CSS + Vite
Happy analyzing!

308
README.md Normal file
View file

@ -0,0 +1,308 @@
# Constellation Analyzer
A React-based visual editor for creating and analyzing Constellation Analyses. Build interactive graphs to examine actors (nodes) and their relationships (edges) with a powerful drag-and-drop interface.
## Features
- **Interactive Graph Visualization**: Built on React Flow for smooth, performant graph editing
- **Customizable Node Types**: Define and configure multiple actor types with distinct visual properties
- **Flexible Edge Types**: Create various relationship categories with different styles and colors
- **Drag-and-Drop Interface**: Intuitive node manipulation and edge creation
- **Real-time Updates**: Instant visual feedback as you build your constellation
- **Type-Safe**: Full TypeScript support for robust development
- **State Management**: Zustand for lightweight, efficient state handling
- **Responsive Design**: Tailwind CSS for modern, adaptive UI
## Technology Stack
- **React 18.2** - UI framework
- **TypeScript 5.2** - Type safety
- **Vite 5.1** - Build tool and dev server
- **React Flow 11.11** - Graph visualization library
- **Zustand 4.5** - State management
- **Tailwind CSS 3.4** - Styling framework
## Getting Started
### Prerequisites
- Node.js 20.x or higher
- npm 9.x or higher
### Installation
```bash
# Install dependencies
npm install
```
### Development
```bash
# Start development server (opens at http://localhost:3000)
npm run dev
```
### Build
```bash
# Build for production
npm run build
# Preview production build
npm run preview
```
### Lint
```bash
# Run ESLint
npm run lint
```
## Project Structure
```
constellation-analyzer/
├── src/
│ ├── components/ # React components
│ │ ├── Editor/ # Main graph editor
│ │ │ └── GraphEditor.tsx
│ │ ├── Nodes/ # Custom node components
│ │ │ └── CustomNode.tsx
│ │ ├── Edges/ # Custom edge components
│ │ │ └── CustomEdge.tsx
│ │ └── Toolbar/ # Editor controls
│ │ └── Toolbar.tsx
│ ├── stores/ # Zustand state stores
│ │ ├── graphStore.ts # Graph state (nodes, edges, types)
│ │ └── editorStore.ts # Editor settings
│ ├── types/ # TypeScript definitions
│ │ └── index.ts
│ ├── utils/ # Utility functions
│ │ ├── nodeUtils.ts
│ │ └── edgeUtils.ts
│ ├── styles/ # Global styles
│ │ └── index.css
│ ├── App.tsx # Root component
│ ├── main.tsx # Entry point
│ └── vite-env.d.ts # Vite types
├── public/ # Static assets
├── index.html # HTML template
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
├── vite.config.ts # Vite config
├── tailwind.config.js # Tailwind config
└── README.md # This file
```
## Usage
### Adding Actors (Nodes)
1. Click any of the actor type buttons in the toolbar (Person, Organization, System, Concept)
2. A new node will appear on the canvas
3. Drag the node to position it
### Creating Relations (Edges)
1. Click and drag from any colored handle (circle) on a node
2. Release over a handle on another node to create a connection
3. The edge will automatically appear with default styling
### Deleting Elements
- **Delete Node**: Select a node and press `Delete` or `Backspace`
- **Delete Edge**: Select an edge and press `Delete` or `Backspace`
### Navigation
- **Pan**: Click and drag on empty canvas space
- **Zoom**: Use mouse wheel or pinch gesture
- **Fit View**: Use the controls in bottom-left corner
- **MiniMap**: View overview and navigate in bottom-right corner
## Core Concepts
### Actors (Nodes)
Actors represent entities in your analysis. Each actor has:
- **Type**: Category (person, organization, system, concept)
- **Label**: Display name
- **Description**: Optional details
- **Position**: X/Y coordinates on canvas
- **Metadata**: Custom properties
### Relations (Edges)
Relations connect actors to show relationships. Each relation has:
- **Type**: Category (collaborates, reports-to, depends-on, influences)
- **Label**: Optional description
- **Style**: Visual representation (solid, dashed, dotted)
- **Source/Target**: Connected actors
### Node Types
Pre-configured actor categories:
- **Person**: Individual (Blue)
- **Organization**: Company/group (Green)
- **System**: Technical system (Orange)
- **Concept**: Abstract idea (Purple)
### Edge Types
Pre-configured relationship categories:
- **Collaborates**: Working together (Blue, solid)
- **Reports To**: Hierarchical (Green, solid)
- **Depends On**: Dependency (Orange, dashed)
- **Influences**: Impact (Purple, dotted)
## Customization
### Adding New Node Types
Edit `/src/stores/graphStore.ts`:
```typescript
const defaultNodeTypes: NodeTypeConfig[] = [
// Add your custom type
{
id: 'custom-type',
label: 'Custom Type',
color: '#ff6b6b',
description: 'My custom actor type'
},
];
```
### Adding New Edge Types
Edit `/src/stores/graphStore.ts`:
```typescript
const defaultEdgeTypes: EdgeTypeConfig[] = [
// Add your custom type
{
id: 'custom-relation',
label: 'Custom Relation',
color: '#ff6b6b',
style: 'solid'
},
];
```
## Architecture Decisions
### Why React Flow?
- React-native components for seamless integration
- Excellent performance with large graphs
- Rich API for customization
- Active community and maintenance
### Why Zustand?
- Lightweight (< 1KB)
- Simple, hook-based API
- No boilerplate compared to Redux
- Perfect for graph state management
### Why Vite?
- Lightning-fast HMR (Hot Module Replacement)
- Modern build tool with ES modules
- Optimized production builds
- Better DX than Create React App
### Why Tailwind CSS?
- Rapid UI development
- Consistent design system
- Small production bundle (unused classes purged)
- Easy responsive design
## Development Guidelines
### ⚠️ Important: Always Use History-Tracked Operations
When modifying graph state in components, **always use `useGraphWithHistory()`** instead of `useGraphStore()` directly:
```typescript
// ✅ CORRECT: Uses history tracking (enables undo/redo)
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
function MyComponent() {
const { addNode, updateNode, deleteNode } = useGraphWithHistory();
const handleAddNode = () => {
addNode(newNode); // Automatically tracked in history
};
}
```
```typescript
// ❌ WRONG: Bypasses history (undo/redo won't work)
import { useGraphStore } from '../../stores/graphStore';
function MyComponent() {
const graphStore = useGraphStore();
const handleAddNode = () => {
graphStore.addNode(newNode); // History not tracked!
};
}
```
**Exception**: Read-only access in presentation components (CustomNode, CustomEdge) is acceptable since it doesn't modify state.
### History-Tracked Operations
All these operations automatically create undo/redo history entries:
- Node operations: `addNode`, `updateNode`, `deleteNode`
- Edge operations: `addEdge`, `updateEdge`, `deleteEdge`
- Type operations: `addNodeType`, `updateNodeType`, `deleteNodeType`, `addEdgeType`, `updateEdgeType`, `deleteEdgeType`
- Utility: `clearGraph`
See `src/hooks/useGraphWithHistory.ts` for complete documentation.
## Next Steps
### Suggested Enhancements
1. **Data Persistence**
- Save/load graphs to/from JSON
- Local storage integration
- Export to PNG/SVG
2. **Advanced Editing**
- Multi-select nodes
- Copy/paste functionality
- ✅ Undo/redo history (implemented - per-document with 50 action limit)
3. **Node/Edge Properties Panel**
- Edit labels and descriptions
- Change types dynamically
- Add custom metadata
4. **Layout Algorithms**
- Auto-arrange nodes
- Hierarchical layout
- Force-directed layout
5. **Analysis Tools**
- Calculate graph metrics
- Find shortest paths
- Identify clusters
6. **Collaboration**
- Real-time multi-user editing
- Version control
- Comments and annotations
## Contributing
This is a new project. Contributions are welcome!
## License
MIT
## Support
For issues and questions, please open an issue on the repository.

279
docs/KEYBOARD_SHORTCUTS.md Normal file
View file

@ -0,0 +1,279 @@
# Keyboard Shortcuts System
## Overview
Constellation Analyzer now features a centralized keyboard shortcut management system that prevents conflicts, provides priority-based handling, and offers built-in documentation through a help modal.
## Architecture
### Core Components
1. **useKeyboardShortcutManager** (`src/hooks/useKeyboardShortcutManager.ts`)
- Core hook that manages shortcut registration and event handling
- Provides conflict detection
- Supports priority-based execution
- Platform-aware (Mac vs Windows/Linux)
2. **KeyboardShortcutContext** (`src/contexts/KeyboardShortcutContext.tsx`)
- React context provider making the shortcut manager available throughout the app
- Ensures single global event listener for all shortcuts
3. **useGlobalShortcuts** (`src/hooks/useGlobalShortcuts.ts`)
- Centralized registration of all application-wide shortcuts
- Single source of truth for what shortcuts exist
4. **KeyboardShortcutsHelp** (`src/components/Common/KeyboardShortcutsHelp.tsx`)
- Modal component displaying all available shortcuts
- Automatically generated from registered shortcuts
- Grouped by category
## Available Shortcuts
### Document Management
- **Ctrl+N** - New Document
- **Ctrl+O** - Open Document Manager
- **Ctrl+S** - Export Document
- **Ctrl+W** - Close Current Document
### Graph Editing
- **Ctrl+Z** - Undo
- **Ctrl+Y** or **Ctrl+Shift+Z** - Redo
- **Delete** or **Backspace** - Delete selected nodes/edges (handled by React Flow)
### Selection
- **Ctrl+A** - Select All (placeholder for future implementation)
- **Escape** - Deselect All (handled by React Flow)
### View
- **F** - Fit View to Content
### Navigation
- **Ctrl+Tab** - Next Document
- **Ctrl+Shift+Tab** - Previous Document
- **?** - Show Keyboard Shortcuts Help
## Implementation Details
### Shortcut Definition
Shortcuts are defined using the `KeyboardShortcut` interface:
```typescript
interface KeyboardShortcut {
id: string; // Unique identifier
description: string; // Shown in help UI
key: string; // Key to press
ctrl?: boolean; // Requires Ctrl/Cmd modifier
shift?: boolean; // Requires Shift modifier
alt?: boolean; // Requires Alt/Option modifier
handler: () => void; // Function to execute
priority?: number; // Higher = executed first (default: 0)
category: ShortcutCategory; // For grouping in help
enabled?: boolean; // Can be disabled (default: true)
}
```
### Platform Detection
The system automatically detects the platform:
- **Mac**: Uses `Cmd` key (metaKey)
- **Windows/Linux**: Uses `Ctrl` key (ctrlKey)
Display strings adapt accordingly:
- Mac: "Cmd+N"
- Windows/Linux: "Ctrl+N"
### Conflict Detection
When registering a shortcut, the system checks for conflicts:
- Same key combination
- Same modifiers
- Different ID
Conflicts are logged to console as warnings but don't prevent registration.
### Priority Handling
If multiple shortcuts match the same key combination:
1. Sort by priority (higher number = higher priority)
2. Execute only the highest priority handler
3. Default priority is 0
Example: Ctrl+Shift+Z has lower priority than Ctrl+Y for redo, so Ctrl+Y is preferred.
## Adding New Shortcuts
### Global Shortcuts
Add to `src/hooks/useGlobalShortcuts.ts`:
```typescript
const shortcutDefinitions: KeyboardShortcut[] = [
// ... existing shortcuts
{
id: 'my-new-shortcut',
description: 'Do Something',
key: 'k',
ctrl: true,
handler: () => doSomething(),
category: 'Graph Editing',
},
];
```
### Component-Specific Shortcuts
Use the context in any component:
```typescript
import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutContext';
function MyComponent() {
const { shortcuts } = useKeyboardShortcuts();
useEffect(() => {
shortcuts.register({
id: 'component-specific',
description: 'Component Action',
key: 'x',
ctrl: true,
handler: () => handleAction(),
category: 'Graph Editing',
});
return () => shortcuts.unregister('component-specific');
}, [shortcuts]);
}
```
### Adding Menu Items
When adding a new shortcut that should appear in the menu, update `MenuBar.tsx`:
```tsx
<button
onClick={() => {
myAction();
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>My Action</span>
<span className="text-xs text-gray-400">Ctrl+K</span>
</button>
```
## Design Decisions
### Why Not Use `?` as a Regular Character
The `?` key doesn't require Shift in the shortcut definition because:
- It's simpler for users to press just `?`
- Consistent with industry standards (VS Code, GitHub, etc.)
- The key value is already `?` when Shift is pressed
### Why Centralized vs Distributed
**Advantages of centralized system:**
- Single source of truth for all shortcuts
- Conflict detection
- Automatic help documentation
- Easier to maintain and audit
- Priority-based resolution
**Disadvantages:**
- Slightly more complex initial setup
- All shortcuts must be registered centrally or cleanup properly
### Why Context vs Global Singleton
Using React Context provides:
- Better integration with React lifecycle
- Automatic cleanup
- Testability
- Type safety
## Migration from Old System
The old `useKeyboardShortcuts` hook has been replaced with `useGlobalShortcuts`. The migration involved:
1. **Before**: Event listeners scattered across components
2. **After**: Centralized registration with automatic documentation
The old hook has been preserved for reference but should not be used for new shortcuts.
## Future Enhancements
### Possible Additions
1. **User-Configurable Shortcuts**
- Allow users to customize key bindings
- Store in localStorage
- UI for rebinding
2. **Shortcut Contexts**
- Different shortcuts active in different app modes
- Disable/enable groups of shortcuts
3. **Chord Shortcuts**
- Multi-key sequences (e.g., "Ctrl+K, Ctrl+S")
- Inspired by VS Code
4. **Shortcut Recording**
- Let users record custom shortcuts
- Visual feedback during recording
5. **Platform-Specific Overrides**
- Different shortcuts for Mac vs Windows
- Better ergonomics per platform
### Excluded from Current Implementation
**Node Type Creation Shortcuts** (e.g., P for Person, O for Organization)
- **Reason**: User-configurable node types make fixed shortcuts inappropriate
- **Alternative**: Context menu (right-click) or toolbar remain the recommended methods
- Users can have custom types like "Department", "Resource", etc., so hardcoded letters wouldn't make sense
## Testing
To test the keyboard shortcut system:
1. **Build the application**: `npm run build`
2. **Start the dev server**: `npm run dev`
3. **Test shortcuts**:
- Press `?` to see all available shortcuts
- Try Ctrl+N for new document
- Try Ctrl+Z/Ctrl+Y for undo/redo
- Try F to fit view
4. **Check conflict detection**:
- Look at browser console during startup
- Verify no conflict warnings appear
## Troubleshooting
### Shortcut Not Working
1. Check browser console for conflict warnings
2. Verify shortcut is registered in `useGlobalShortcuts`
3. Check if handler is properly passed (not undefined)
4. Verify `enabled` is not set to false
5. Check if another shortcut has higher priority
### Shortcut Not Appearing in Help
1. Verify `enabled` is not set to false
2. Check the category is correct
3. Ensure shortcut is registered before help modal opens
### Conflicts
If you see conflict warnings:
1. Change one of the conflicting shortcuts
2. Or use priority to determine which should win
3. Or disable one of the shortcuts conditionally
## References
- UX Analysis: `UX_ANALYSIS.md` (lines 58-104)
- Implementation docs: Inline comments in source files
- React Flow keyboard handling: https://reactflow.dev/learn/advanced-use/accessibility

564
docs/MULTI_FILE_PLAN.md Normal file
View file

@ -0,0 +1,564 @@
# Multi-File/Multi-Document Architecture Plan
## Overview
Transform Constellation Analyzer from a single-document app to a multi-document workspace with tabbed interface, leveraging the existing persistence infrastructure.
---
## 1. Core Concept: Workspace vs Document
### Current Architecture
- **Single Document**: One graph with its nodes, edges, nodeTypes, edgeTypes
- **Auto-save**: Automatically saves to `localStorage` under one key
### New Architecture
- **Workspace**: Container for multiple documents + workspace settings
- **Documents**: Individual constellation analyses (each is a `ConstellationDocument`)
- **Active Document**: The currently visible/editable document in a tab
- **Workspace Settings**: Cross-document preferences, recent files list, tab order
---
## 2. Data Model Evolution
### Workspace Structure
```typescript
interface WorkspaceState {
// Workspace metadata
workspaceId: string; // Unique workspace ID
workspaceName: string; // "My Workspace"
// Document management
documents: Map<string, ConstellationDocument>; // documentId -> document
documentOrder: string[]; // Order of tabs
activeDocumentId: string | null; // Currently visible document
// Document metadata (separate from document content for performance)
documentMetadata: Map<string, DocumentMetadata>;
// Workspace-level settings
settings: WorkspaceSettings;
}
interface DocumentMetadata {
id: string;
title: string; // User-friendly name
fileName?: string; // If loaded from file
filePath?: string; // For future file system access
isDirty: boolean; // Has unsaved changes
lastModified: string; // ISO timestamp
thumbnail?: string; // Base64 mini-preview (optional)
color?: string; // Tab color identifier
}
interface WorkspaceSettings {
maxOpenDocuments: number; // Limit tabs (e.g., 10)
autoSaveEnabled: boolean;
defaultNodeTypes: NodeTypeConfig[]; // Workspace defaults
defaultEdgeTypes: EdgeTypeConfig[]; // Workspace defaults
recentFiles: RecentFile[]; // Recently opened files
}
interface RecentFile {
path: string;
title: string;
lastOpened: string;
thumbnail?: string;
}
```
### Updated ConstellationDocument
```typescript
// Already exists, but add:
interface ConstellationDocument {
// ... existing fields
metadata: {
// ... existing fields
documentId: string; // NEW: Unique document ID
title: string; // NEW: Document title
};
graph: {
// ... existing: nodes, edges, nodeTypes, edgeTypes
};
}
```
---
## 3. Storage Strategy
### LocalStorage Key Structure
```typescript
const STORAGE_KEYS = {
// Workspace-level
WORKSPACE_STATE: 'constellation:workspace:v1',
WORKSPACE_SETTINGS: 'constellation:workspace:settings:v1',
// Document-level (dynamic)
DOCUMENT_PREFIX: 'constellation:document:v1:', // + documentId
DOCUMENT_METADATA_PREFIX: 'constellation:meta:v1:', // + documentId
// Legacy (for migration)
LEGACY_GRAPH_STATE: 'constellation:graph:v1',
};
```
### Storage Pattern
```
localStorage:
├─ constellation:workspace:v1
│ → { workspaceId, workspaceName, documentOrder, activeDocumentId }
├─ constellation:workspace:settings:v1
│ → { maxOpenDocuments, autoSaveEnabled, defaultNodeTypes, ... }
├─ constellation:document:v1:doc-123
│ → Full ConstellationDocument
├─ constellation:meta:v1:doc-123
│ → DocumentMetadata (for quick loading)
└─ ... (more documents)
```
**Benefits:**
- **Partial loading**: Load metadata first, full documents on demand
- **Quota management**: Can delete old documents individually
- **Performance**: Don't load all documents at startup
- **Granular auto-save**: Only save changed documents
---
## 4. Architecture Changes
### New Store: `workspaceStore.ts`
```typescript
interface WorkspaceStore {
// Workspace state
workspaceId: string;
workspaceName: string;
documentOrder: string[];
activeDocumentId: string | null;
documentMetadata: Map<string, DocumentMetadata>;
settings: WorkspaceSettings;
// Document management
documents: Map<string, ConstellationDocument>; // Only loaded docs in memory
// Actions
createDocument: (title?: string) => string; // Returns documentId
loadDocument: (documentId: string) => void;
closeDocument: (documentId: string) => void;
deleteDocument: (documentId: string) => void;
renameDocument: (documentId: string, newTitle: string) => void;
duplicateDocument: (documentId: string) => string;
switchToDocument: (documentId: string) => void;
reorderDocuments: (newOrder: string[]) => void;
importDocumentFromFile: (file: File) => Promise<string>;
exportDocument: (documentId: string) => void;
// Workspace actions
saveWorkspace: () => void;
loadWorkspace: () => void;
clearWorkspace: () => void;
}
```
### Updated `graphStore.ts`
```typescript
// REFACTOR: Make graphStore document-scoped
interface GraphStore {
// Remove persistence - now handled by workspace
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
// Same CRUD operations, but no auto-save to localStorage
// Instead, mark document as dirty in workspace
addNode: (node: Actor) => void;
updateNode: (id: string, updates: Partial<Actor>) => void;
// ... etc
// NEW: Hook to notify workspace of changes
_onChangeCallback?: () => void;
}
// Create instance per document
const createGraphStore = (documentId: string) => {
return create<GraphStore>((set) => ({
// ... existing implementation
// But call _onChangeCallback on mutations
}));
};
```
### Store Relationship
```
workspaceStore (singleton)
├─ Manages document metadata
├─ Manages active document
└─ Delegates graph operations to active graphStore
graphStoreInstances (Map<documentId, GraphStore>)
├─ One instance per loaded document
├─ Active instance linked to UI
└─ Notifies workspace on changes
```
---
## 5. UI Changes
### New Components
#### 1. **DocumentTabs** (top of editor)
```tsx
<DocumentTabs>
<Tab
id="doc-123"
title="Analysis 1"
isActive={true}
isDirty={true}
onClose={handleClose}
onClick={handleSwitch}
/>
<Tab
id="doc-456"
title="Analysis 2"
isActive={false}
isDirty={false}
onClose={handleClose}
onClick={handleSwitch}
/>
<NewTabButton onClick={handleNew} />
</DocumentTabs>
```
Features:
- Close button (X) with unsaved warning
- Drag-to-reorder tabs
- Double-click to rename
- Right-click context menu (rename, duplicate, delete, export)
- Visual indicator for unsaved changes (dot or asterisk)
- Tab overflow handling (scroll or dropdown)
#### 2. **DocumentManager** (sidebar or modal)
```tsx
<DocumentManager>
<DocumentGrid>
{documents.map(doc => (
<DocumentCard
key={doc.id}
title={doc.title}
thumbnail={doc.thumbnail}
lastModified={doc.lastModified}
onClick={() => openDocument(doc.id)}
onDelete={() => deleteDocument(doc.id)}
/>
))}
</DocumentGrid>
<ImportButton />
<NewDocumentButton />
</DocumentManager>
```
#### 3. **UnsavedChangesDialog**
```tsx
<UnsavedChangesDialog
documentTitle="Analysis 1"
onSave={handleSave}
onDiscard={handleDiscard}
onCancel={handleCancel}
/>
```
### Updated App Structure
```tsx
<App>
<Header>
<Title>Constellation Analyzer</Title>
<WorkspaceMenu />
</Header>
<DocumentTabs /> {/* NEW */}
<Toolbar />
<GraphEditor
documentId={activeDocumentId}
graphStore={activeGraphStore}
/>
<DocumentManager isOpen={showManager} /> {/* NEW */}
</App>
```
---
## 6. Persistence Flow
### Auto-Save Strategy
```typescript
// Workspace-level debounced save
let workspaceSaveTimeout: NodeJS.Timeout;
const saveWorkspace = debounce(() => {
// Save workspace metadata
localStorage.setItem(
STORAGE_KEYS.WORKSPACE_STATE,
JSON.stringify(workspaceState)
);
}, 1000);
// Document-level debounced save
const saveDocument = debounce((documentId: string) => {
const doc = documents.get(documentId);
if (!doc) return;
// Save full document
localStorage.setItem(
`${STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`,
JSON.stringify(doc)
);
// Update metadata
const meta = documentMetadata.get(documentId);
if (meta) {
meta.isDirty = false;
meta.lastModified = new Date().toISOString();
localStorage.setItem(
`${STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`,
JSON.stringify(meta)
);
}
}, 1000);
// On graph change
graphStore.subscribe((state) => {
markDocumentDirty(activeDocumentId);
saveDocument(activeDocumentId);
});
```
### Startup Sequence
```
1. App loads
2. Load workspace metadata from localStorage
3. Load all document metadata (lightweight)
4. If activeDocumentId exists, load that document
5. Create graphStore instance for active document
6. Render UI with tabs and active graph
```
### Tab Switch Flow
```
1. User clicks different tab
2. Check if current document has unsaved changes
→ If yes and auto-save disabled, show dialog
3. Save current document (if needed)
4. Load target document from localStorage (if not in memory)
5. Switch activeDocumentId
6. Update graphStore reference
7. GraphEditor re-renders with new data
```
---
## 7. Migration Strategy
### Migrating from Single-Doc to Multi-Doc
```typescript
// src/stores/persistence/migration-workspace.ts
export function migrateToWorkspace(): WorkspaceState | null {
// Check for legacy data
const legacyData = localStorage.getItem(STORAGE_KEYS.LEGACY_GRAPH_STATE);
if (!legacyData) return null;
try {
const oldDoc = JSON.parse(legacyData) as ConstellationDocument;
// Create first document from legacy data
const documentId = generateDocumentId();
const newDoc: ConstellationDocument = {
...oldDoc,
metadata: {
...oldDoc.metadata,
documentId,
title: 'Imported Analysis',
},
};
// Create workspace
const workspace: WorkspaceState = {
workspaceId: generateWorkspaceId(),
workspaceName: 'My Workspace',
documentOrder: [documentId],
activeDocumentId: documentId,
documentMetadata: new Map([[documentId, {
id: documentId,
title: 'Imported Analysis',
isDirty: false,
lastModified: new Date().toISOString(),
}]]),
documents: new Map([[documentId, newDoc]]),
settings: {
maxOpenDocuments: 10,
autoSaveEnabled: true,
defaultNodeTypes: oldDoc.graph.nodeTypes,
defaultEdgeTypes: oldDoc.graph.edgeTypes,
recentFiles: [],
},
};
// Save to new format
saveWorkspace(workspace);
saveDocument(documentId, newDoc);
// Remove legacy data
localStorage.removeItem(STORAGE_KEYS.LEGACY_GRAPH_STATE);
return workspace;
} catch (error) {
console.error('Migration failed:', error);
return null;
}
}
```
---
## 8. Implementation Phases
### Phase 1: Foundation (Multi-Doc Store)
- [ ] Create `workspaceStore.ts` with document management
- [ ] Refactor `graphStore.ts` to be instance-based
- [ ] Update storage keys and persistence layer
- [ ] Implement migration from single-doc to multi-doc
- [ ] Basic create/load/delete document functionality
### Phase 2: UI - Tabs
- [ ] Create `DocumentTabs` component
- [ ] Implement tab switching logic
- [ ] Add close/rename tab functionality
- [ ] Handle unsaved changes dialog
- [ ] Visual indicators (dirty state, active tab)
### Phase 3: Document Management
- [ ] Create `DocumentManager` component (grid view)
- [ ] Implement import from file → new document
- [ ] Implement export single document
- [ ] Add duplicate document functionality
- [ ] Thumbnail generation (optional)
### Phase 4: Advanced Features
- [ ] Drag-to-reorder tabs
- [ ] Recent files list
- [ ] Tab context menu (right-click)
- [ ] Keyboard shortcuts (Ctrl+Tab, Ctrl+W, etc.)
- [ ] Search/filter documents in manager
### Phase 5: Polish & Optimization
- [ ] Lazy loading: Load documents on-demand
- [ ] Memory management: Unload inactive documents
- [ ] Tab overflow handling (scroll or dropdown)
- [ ] Export all documents as ZIP
- [ ] Workspace import/export
---
## 9. Key Technical Decisions
### 1. **Store Architecture: Singleton Workspace + Instance-based Graph**
- **Why**: GraphStore contains mutable state that must be isolated per document
- **How**: `Map<documentId, GraphStore>` managed by workspace
### 2. **Lazy Document Loading**
- **Why**: Don't load 20 full documents at startup
- **How**: Load metadata first, full documents when tab is activated
### 3. **Document ID Generation**
```typescript
const generateDocumentId = () =>
`doc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
```
### 4. **Auto-Save per Document**
- Each document saves independently
- Debounced per document (not global)
- Workspace state saves separately (tab order, active doc)
### 5. **Unsaved Changes Handling**
```typescript
const canCloseDocument = (docId: string): boolean => {
const meta = documentMetadata.get(docId);
if (!meta?.isDirty) return true;
return window.confirm(`"${meta.title}" has unsaved changes. Close anyway?`);
};
```
### 6. **Default Values Strategy**
- Workspace has default nodeTypes/edgeTypes
- New documents inherit workspace defaults
- Individual documents can customize their types
- Workspace defaults can be updated from any document
---
## 10. Future Enhancements
### Potential Features
1. **Document Templates**: Pre-configured node/edge types
2. **Document Linking**: Reference nodes across documents
3. **Workspace Sharing**: Export entire workspace to file
4. **Cloud Sync**: Replace localStorage with backend
5. **Collaborative Editing**: Multi-user support
6. **Version History**: Document snapshots
7. **Document Tags/Categories**: Organize many documents
8. **Search Across Documents**: Find nodes/edges globally
---
## 11. Risk Mitigation
### LocalStorage Quota
- **Risk**: 5-10MB limit, could fill with many documents
- **Mitigation**:
- Show storage usage indicator
- Warn when approaching limit
- Offer to delete old documents
- Implement document export before delete
### Performance
- **Risk**: Many documents slow down UI
- **Mitigation**:
- Lazy loading
- Virtual scrolling for document manager
- Limit open tabs (configurable)
- Unload inactive documents from memory
### Data Loss
- **Risk**: Corrupted document affects all
- **Mitigation**:
- Each document stored separately
- Backup on export
- Migration safety checks
---
## Summary
This multi-file architecture:
✅ Leverages existing `ConstellationDocument` schema
✅ Reuses persistence infrastructure (saver, loader, validation)
✅ Maintains backward compatibility via migration
✅ Provides professional multi-document UX
✅ Scales to many documents with lazy loading
✅ Keeps data safe with per-document isolation
**Key Insight**: The existing persistence layer is perfectly suited for this - we just need to change from "one document in localStorage" to "many documents in localStorage", managed by a workspace orchestrator.

339
docs/PERSISTENCE_PLAN.md Normal file
View file

@ -0,0 +1,339 @@
# Local Storage Persistence Plan for Constellation Analyzer
## 1. Data Format Specification
### JSON Schema Structure
```typescript
interface ConstellationDocument {
// Metadata
metadata: {
version: string; // Schema version (e.g., "1.0.0")
appName: string; // "constellation-analyzer"
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
lastSavedBy: string; // Browser fingerprint or "unknown"
};
// Graph state
graph: {
nodes: SerializedActor[]; // Simplified Actor[] without React Flow internals
edges: SerializedRelation[]; // Simplified Relation[] without React Flow internals
nodeTypes: NodeTypeConfig[]; // Already serializable
edgeTypes: EdgeTypeConfig[]; // Already serializable
};
// Editor settings (optional - may persist separately)
editorSettings?: EditorSettings;
}
// Simplified node structure for storage
interface SerializedActor {
id: string;
type: string; // React Flow node type (e.g., "custom")
position: { x: number; y: number };
data: ActorData;
selected?: boolean;
dragging?: boolean;
}
// Simplified edge structure for storage
interface SerializedRelation {
id: string;
source: string;
target: string;
type?: string; // React Flow edge type
data: RelationData;
sourceHandle?: string | null;
targetHandle?: string | null;
}
```
**Rationale:**
- Separate metadata for versioning and migration support
- Exclude React Flow-specific runtime properties (measured, width, height, etc.)
- Store minimal required data to reconstruct full state
- Include timestamps for debugging and conflict resolution
---
## 2. Storage Strategy
### Architecture Choice: **Zustand Middleware Pattern**
**Recommended approach:** Create a custom Zustand middleware that intercepts state changes and persists to localStorage.
### Storage Keys Strategy
```typescript
const STORAGE_KEYS = {
GRAPH_STATE: 'constellation:graph:v1', // Main graph data
EDITOR_SETTINGS: 'constellation:editor:v1', // Editor preferences
AUTOSAVE_FLAG: 'constellation:autosave', // Flag for crash recovery
LAST_SAVED: 'constellation:lastSaved', // Timestamp
};
```
**Why separate keys:**
- Allow partial updates (save graph independently from settings)
- Different persistence strategies (graph = debounced, settings = immediate)
- Easier to manage storage quota
### Debouncing Strategy
```typescript
// Debounce configuration
const DEBOUNCE_CONFIG = {
DELAY: 1000, // 1 second after last change
MAX_WAIT: 5000, // Force save every 5 seconds
THROTTLE_NODE_DRAG: 500, // Faster saves during drag operations
};
```
**Implementation approach:**
- Use `lodash.debounce` or custom implementation
- Different debounce times for different operations:
- Node dragging: 500ms (frequent but predictable)
- Adding/deleting: 1000ms (less frequent)
- Typing in properties: 1000ms (standard)
- Max wait ensures data isn't lost even during continuous editing
### When to Save
**Auto-save triggers:**
1. Any GraphStore state mutation (nodes, edges, nodeTypes, edgeTypes)
2. EditorStore settings changes (optional, can be immediate)
3. Before window unload (emergency save)
4. After successful import (to persist imported state)
**Don't save:**
- Temporary UI state (selectedRelationType, hover states)
- React Flow internals (viewport, connection state)
---
## 3. Loading Strategy
### Bootstrap Sequence
```
1. App starts → Check for stored data
2. Validate schema version
3. If valid: Deserialize → Hydrate store
4. If invalid: Attempt migration OR use defaults
5. If corrupted: Show recovery dialog → Load defaults
6. Set up auto-save listeners
```
### Validation Approach
Use runtime validation to ensure data integrity.
**Validation checks:**
- Schema version exists and is supported
- All required fields present
- Node IDs are unique
- Edge source/target references exist in nodes
- Type references (node.data.type) exist in nodeTypes
- Color values are valid hex codes
### Hydration Process
Initialize store with loaded data, adding back React Flow properties with defaults.
---
## 4. Error Handling Strategy
### Error Categories
```typescript
enum PersistenceError {
QUOTA_EXCEEDED = 'quota_exceeded',
CORRUPTED_DATA = 'corrupted_data',
VERSION_MISMATCH = 'version_mismatch',
PARSE_ERROR = 'parse_error',
STORAGE_UNAVAILABLE = 'storage_unavailable',
}
```
### Error Recovery Strategies
| Error | Strategy | User Experience |
|-------|----------|-----------------|
| **Quota Exceeded** | 1. Show warning<br>2. Compress data (remove whitespace)<br>3. Offer export to file<br>4. Continue without auto-save | Toast notification: "Storage full. Save to file to preserve work." |
| **Corrupted Data** | 1. Attempt partial recovery<br>2. Load default state<br>3. Log error for debugging<br>4. Offer to restore from backup | Dialog: "Previous session corrupted. Starting fresh." + Show details |
| **Version Mismatch** | 1. Attempt migration<br>2. If migration fails, load defaults<br>3. Preserve old data as backup | Toast: "Updated to new version. Data migrated successfully." |
| **Parse Error** | 1. Clear corrupted data<br>2. Load defaults<br>3. Log error | Toast: "Unable to restore previous session." |
| **Storage Unavailable** | 1. Detect private/incognito mode<br>2. Disable auto-save<br>3. Show warning | Banner: "Auto-save disabled (private mode). Export to save work." |
### Multi-Tab Synchronization
**Problem:** Multiple tabs open, each saving independently → conflicts
**Solution:** Use `storage` event listener
**Recommendation:** Start with last-write-wins. Add conflict resolution later if needed.
---
## 5. Code Architecture
### Folder Structure
```
/src
/stores
/persistence
constants.ts # Storage keys, config
types.ts # Serialization types
loader.ts # Load and validate data
saver.ts # Save and serialize data
middleware.ts # Zustand middleware for auto-save
migrations.ts # Version migration logic (future)
hooks.ts # React hooks for persistence features (future)
graphStore.ts # Enhanced with persistence
editorStore.ts # Enhanced with persistence
```
### Module Responsibilities
**constants.ts**
- Storage keys
- Debounce configuration
- Current schema version
**types.ts**
- ConstellationDocument interface
- SerializedActor, SerializedRelation interfaces
- PersistenceError enum
**loader.ts**
- Reads from localStorage
- Validates schema
- Deserializes data
- Returns typed ConstellationDocument or null
**saver.ts**
- Serializes current store state
- Writes to localStorage
- Handles quota errors
- Updates lastSaved timestamp
**middleware.ts**
- Intercepts Zustand state changes
- Triggers debounced saves
- Filters what gets persisted
**migrations.ts** (Phase 3)
- Version detection
- Data transformation between versions
- Backward compatibility
**hooks.ts** (Phase 3)
- `usePersistence()` - Monitor save status
- `useAutoSave()` - Manual save trigger
- `useStorageStats()` - Storage quota info
---
## 6. Migration Strategy
### Version Naming Convention
Use semantic versioning: `MAJOR.MINOR.PATCH`
- **MAJOR:** Breaking changes (incompatible schema)
- **MINOR:** New fields (backward compatible)
- **PATCH:** Bug fixes, no schema changes
### Migration Registry
```typescript
// migrations.ts
type Migration = (old: any) => ConstellationDocument;
const MIGRATIONS: Record<string, Migration> = {
'0.9.0->1.0.0': (old) => {
// Example: Rename field
return {
...old,
graph: {
...old.graph,
nodes: old.graph.actors.map(actor => ({
...actor,
data: { ...actor.data, label: actor.data.name },
})),
},
};
},
};
```
---
## 7. Implementation Phases
### Phase 1: Core Persistence (MVP) ✅ IMPLEMENTING NOW
- [x] Create serialization types
- [x] Create constants
- [x] Implement saver.ts with debouncing
- [x] Implement loader.ts with basic validation
- [x] Add persistence middleware to graphStore
- [ ] Test save/load cycle
### Phase 2: Error Handling
- [ ] Add quota exceeded handling
- [ ] Add corrupted data recovery
- [ ] Add storage unavailable detection
- [ ] Create user-facing error messages
### Phase 3: Advanced Features
- [ ] Multi-tab synchronization
- [ ] Migration system
- [ ] Backup rotation
- [ ] Storage stats monitoring
### Phase 4: Polish
- [ ] Performance optimization
- [ ] Compression for large graphs
- [ ] Export/import integration
- [ ] User preferences for auto-save behavior
---
## 8. Testing Strategy
### Test Cases
**Manual Tests (Phase 1):**
- Create nodes/edges → Reload page → Verify restored
- Edit node properties → Reload → Verify persisted
- Add custom actor types → Reload → Verify persisted
- Create relations → Reload → Verify persisted
**Integration Tests (Phase 2):**
- Save → Clear → Load → Verify state matches
- Corrupted data → Loads defaults
- Quota exceeded → Handles gracefully
**E2E Tests (Phase 3):**
- Multiple tabs → Changes sync
- Version migration works
---
## Summary
**Key Technical Decisions:**
1. **Architecture:** Zustand middleware pattern for clean separation
2. **Storage:** localStorage with versioned schema
3. **Serialization:** Minimal JSON format, exclude React Flow internals
4. **Debouncing:** 1s delay, 5s max wait, operation-specific tuning
5. **Validation:** Runtime validation on load
6. **Errors:** Graceful degradation with user notifications
7. **Multi-tab:** Storage event listener with last-write-wins
8. **Migration:** Version registry with transformation functions
**Current Version:** 1.0.0
**Current Phase:** Phase 1 (MVP Implementation)

382
docs/PROJECT_SUMMARY.md Normal file
View file

@ -0,0 +1,382 @@
# Constellation Analyzer - Project Summary
## Overview
Successfully scaffolded a complete, production-ready React application for creating and analyzing Constellation Analyses through an interactive visual graph editor.
## What Was Created
### 1. Core Application Files
- **`/home/jbruhn/dev/constellation-analyzer/index.html`** - HTML entry point
- **`/home/jbruhn/dev/constellation-analyzer/src/main.tsx`** - React application entry
- **`/home/jbruhn/dev/constellation-analyzer/src/App.tsx`** - Root component with layout
### 2. Component Architecture
#### Editor Components
- **`/home/jbruhn/dev/constellation-analyzer/src/components/Editor/GraphEditor.tsx`**
- Main graph visualization component
- Wraps React Flow with custom configuration
- Handles node/edge state synchronization
- Implements drag-and-drop functionality
- Includes background grid, controls, and minimap
#### Node Components
- **`/home/jbruhn/dev/constellation-analyzer/src/components/Nodes/CustomNode.tsx`**
- Custom actor representation
- Type-based visual styling
- Four connection handles (top, right, bottom, left)
- Displays label, type badge, and optional description
#### Edge Components
- **`/home/jbruhn/dev/constellation-analyzer/src/components/Edges/CustomEdge.tsx`**
- Custom relationship visualization
- Bezier curve paths
- Type-based coloring and styling (solid, dashed, dotted)
- Optional edge labels
#### Toolbar Components
- **`/home/jbruhn/dev/constellation-analyzer/src/components/Toolbar/Toolbar.tsx`**
- Node type selection buttons
- Clear graph functionality
- User instructions
### 3. State Management (Zustand)
- **`/home/jbruhn/dev/constellation-analyzer/src/stores/graphStore.ts`**
- Graph state (nodes, edges)
- Node type configurations (Person, Organization, System, Concept)
- Edge type configurations (Collaborates, Reports To, Depends On, Influences)
- CRUD operations for nodes and edges
- Type management
- **`/home/jbruhn/dev/constellation-analyzer/src/stores/editorStore.ts`**
- Editor settings (grid, snap, pan, zoom)
- UI preferences
### 4. TypeScript Type Definitions
- **`/home/jbruhn/dev/constellation-analyzer/src/types/index.ts`**
- `Actor` - Node type with ActorData
- `Relation` - Edge type with RelationData
- `NodeTypeConfig` - Node type configuration
- `EdgeTypeConfig` - Edge type configuration
- `GraphState` - Overall graph state
- `EditorSettings` - Editor preferences
- `GraphActions` & `EditorActions` - Store action interfaces
### 5. Utility Functions
- **`/home/jbruhn/dev/constellation-analyzer/src/utils/nodeUtils.ts`**
- `generateNodeId()` - Unique ID generation
- `createNode()` - Node factory function
- `validateNodeData()` - Data validation
- **`/home/jbruhn/dev/constellation-analyzer/src/utils/edgeUtils.ts`**
- `generateEdgeId()` - Unique ID generation
- `createEdge()` - Edge factory function
- `validateEdgeData()` - Data validation
### 6. Styling
- **`/home/jbruhn/dev/constellation-analyzer/src/styles/index.css`**
- Tailwind CSS imports
- Global styles
- React Flow customizations
- Smooth transitions
### 7. Configuration Files
- **`/home/jbruhn/dev/constellation-analyzer/package.json`** - Dependencies and scripts
- **`/home/jbruhn/dev/constellation-analyzer/tsconfig.json`** - TypeScript configuration (strict mode)
- **`/home/jbruhn/dev/constellation-analyzer/tsconfig.node.json`** - Node-specific TypeScript config
- **`/home/jbruhn/dev/constellation-analyzer/vite.config.ts`** - Vite build configuration
- **`/home/jbruhn/dev/constellation-analyzer/tailwind.config.js`** - Tailwind CSS configuration
- **`/home/jbruhn/dev/constellation-analyzer/postcss.config.js`** - PostCSS configuration
- **`/home/jbruhn/dev/constellation-analyzer/.eslintrc.cjs`** - ESLint configuration
- **`/home/jbruhn/dev/constellation-analyzer/.gitignore`** - Git ignore rules
### 8. Documentation
- **`/home/jbruhn/dev/constellation-analyzer/README.md`** - Comprehensive project documentation
- **`/home/jbruhn/dev/constellation-analyzer/CLAUDE.md`** - Project guidance (already existed)
## Dependencies Installed
### Production Dependencies
- **react** (^18.2.0) - UI framework
- **react-dom** (^18.2.0) - React DOM rendering
- **reactflow** (^11.11.0) - Graph visualization library
- **zustand** (^4.5.0) - State management
### Development Dependencies
- **@types/react** (^18.2.55) - React type definitions
- **@types/react-dom** (^18.2.19) - React DOM type definitions
- **@typescript-eslint/eslint-plugin** (^6.21.0) - TypeScript linting
- **@typescript-eslint/parser** (^6.21.0) - TypeScript parser
- **@vitejs/plugin-react** (^4.2.1) - Vite React plugin
- **autoprefixer** (^10.4.17) - CSS autoprefixing
- **eslint** (^8.56.0) - JavaScript linting
- **eslint-plugin-react-hooks** (^4.6.0) - React hooks linting
- **eslint-plugin-react-refresh** (^0.4.5) - Fast refresh linting
- **postcss** (^8.4.35) - CSS processing
- **tailwindcss** (^3.4.1) - Utility-first CSS framework
- **typescript** (^5.2.2) - TypeScript compiler
- **vite** (^5.1.0) - Build tool and dev server
## Key Architectural Decisions
### 1. React Flow
**Why**: React-native components, excellent performance, rich API, active maintenance, perfect for graph visualization
### 2. Zustand
**Why**: Lightweight (<1KB), simple hook-based API, no boilerplate, ideal for graph state management
### 3. Vite
**Why**: Lightning-fast HMR, modern ES modules, optimized builds, superior developer experience
### 4. Tailwind CSS
**Why**: Rapid development, consistent design system, small production bundle, easy responsive design
### 5. TypeScript (Strict Mode)
**Why**: Type safety for complex graph structures, better IDE support, catch errors at compile time
## What Works in This Initial Version
1. **Interactive Graph Canvas**
- Renders with React Flow
- Pan and zoom functionality
- Background grid display
- MiniMap navigation
2. **Add Actors/Nodes**
- Click toolbar buttons to add nodes
- Four pre-configured types: Person, Organization, System, Concept
- Each type has distinct colors
- Nodes appear at random positions
3. **Create Relations/Edges**
- Drag from any node handle
- Connect to another node's handle
- Edges automatically created with default type
- Visual feedback during connection
4. **Edit Graph**
- Drag nodes to reposition
- Delete nodes (selects and press Delete/Backspace)
- Delete edges (select and press Delete/Backspace)
- Clear entire graph with button
5. **Visual Customization**
- Nodes display type badges with colors
- Nodes show labels
- Edges have type-based styling (solid, dashed, dotted)
- Selected elements highlighted
6. **Responsive Layout**
- Header with project title
- Toolbar with controls
- Full-screen graph editor
- Tailwind responsive classes
## How to Run
### Install Dependencies
```bash
cd /home/jbruhn/dev/constellation-analyzer
npm install
```
### Start Development Server
```bash
npm run dev
```
Opens at http://localhost:3000
### Build for Production
```bash
npm run build
```
### Preview Production Build
```bash
npm run preview
```
### Run Linter
```bash
npm run lint
```
## Project Structure
```
constellation-analyzer/
├── public/
│ └── vite.svg # Favicon
├── src/
│ ├── components/
│ │ ├── Editor/
│ │ │ └── GraphEditor.tsx # Main graph canvas
│ │ ├── Nodes/
│ │ │ └── CustomNode.tsx # Actor node component
│ │ ├── Edges/
│ │ │ └── CustomEdge.tsx # Relation edge component
│ │ └── Toolbar/
│ │ └── Toolbar.tsx # Control panel
│ ├── stores/
│ │ ├── graphStore.ts # Graph state management
│ │ └── editorStore.ts # Editor settings
│ ├── types/
│ │ └── index.ts # TypeScript definitions
│ ├── utils/
│ │ ├── nodeUtils.ts # Node helper functions
│ │ └── edgeUtils.ts # Edge helper functions
│ ├── styles/
│ │ └── index.css # Global styles + Tailwind
│ ├── App.tsx # Root component
│ ├── main.tsx # Entry point
│ └── vite-env.d.ts # Vite types
├── index.html # HTML template
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
├── vite.config.ts # Vite config
├── tailwind.config.js # Tailwind config
├── postcss.config.js # PostCSS config
├── .eslintrc.cjs # ESLint config
├── .gitignore # Git ignore
├── README.md # Documentation
└── CLAUDE.md # Project guidance
```
## Suggested Next Steps for Development
### Phase 1: Enhanced Editing
1. **Node Property Editor**
- Side panel to edit node labels and descriptions
- Change node type dynamically
- Add custom metadata fields
2. **Edge Property Editor**
- Edit edge labels
- Change edge type and style
- Set relationship strength
3. **Multi-Select**
- Select multiple nodes with Shift+Click
- Drag multiple nodes together
- Bulk delete operations
4. **Undo/Redo**
- History tracking for all actions
- Keyboard shortcuts (Ctrl+Z, Ctrl+Y)
### Phase 2: Data Persistence
1. **Save/Load Graphs**
- Export to JSON format
- Import from JSON
- Local storage auto-save
2. **Export Visualizations**
- Export to PNG image
- Export to SVG vector
- PDF export for reports
### Phase 3: Advanced Features
1. **Layout Algorithms**
- Auto-arrange nodes (force-directed, hierarchical)
- Align selected nodes
- Distribute evenly
2. **Analysis Tools**
- Calculate graph metrics (density, centrality)
- Find shortest paths
- Identify clusters/communities
3. **Custom Types**
- UI to create new node types
- UI to create new edge types
- Save type configurations
### Phase 4: Collaboration
1. **Backend Integration**
- REST API for graph storage
- User authentication
- Share graphs with URLs
2. **Real-time Collaboration**
- WebSocket integration
- Multi-user editing
- Cursor tracking
3. **Comments & Annotations**
- Add notes to nodes/edges
- Discussion threads
- Version history
### Phase 5: Polish
1. **Accessibility**
- Keyboard navigation improvements
- Screen reader support
- High contrast mode
2. **Performance**
- Virtual rendering for large graphs
- Progressive loading
- Optimized re-renders
3. **Mobile Support**
- Touch gesture improvements
- Mobile-optimized toolbar
- Responsive layout enhancements
## Testing the Application
### Basic Workflow Test
1. Start dev server: `npm run dev`
2. Add a "Person" node
3. Add an "Organization" node
4. Drag from Person to Organization to create an edge
5. Move nodes around
6. Select and delete an edge
7. Clear the graph
### Expected Behavior
- Nodes appear when buttons clicked
- Nodes can be dragged smoothly
- Edges connect nodes visually
- Selection highlights elements
- Deletion removes elements
- Graph clears with confirmation
## Build Verification
The project has been successfully built and verified:
- TypeScript compilation: PASSED
- Vite production build: PASSED
- Output bundle size: ~300KB (uncompressed)
- No TypeScript errors
- No build warnings
## Notes
- All paths provided are absolute paths as required
- Modern React patterns used (hooks, functional components)
- Strict TypeScript mode enabled for type safety
- ESLint configured for code quality
- Tailwind CSS optimized for production (unused classes purged)
- Git repository already initialized
- Node version: 20.18.1
- NPM version: 9.2.0
## Success Criteria Met
- Complete React application scaffolded
- All dependencies installed
- TypeScript properly configured
- React Flow integrated and working
- Zustand state management implemented
- Tailwind CSS styling applied
- Basic graph editing functionality working
- Production build successful
- Comprehensive documentation provided
- Runnable with `npm install && npm run dev`

View file

@ -0,0 +1,294 @@
# Undo/Redo System Implementation
## Overview
The Constellation Analyzer now features a comprehensive per-document undo/redo system that allows users to safely experiment with their graphs without fear of permanent mistakes.
**Key Features:**
- ✅ **Per-Document History**: Each document maintains its own independent undo/redo stack (max 50 actions)
- ✅ **Keyboard Shortcuts**: Ctrl+Z (undo), Ctrl+Y or Ctrl+Shift+Z (redo)
- ✅ **Visual UI**: Undo/Redo buttons in toolbar with disabled states and tooltips
- ✅ **Action Descriptions**: Hover tooltips show what action will be undone/redone
- ✅ **Automatic Tracking**: All graph operations are automatically tracked
- ✅ **Debounced Moves**: Node dragging is debounced to avoid cluttering history
- ✅ **Document Switching**: History is preserved when switching between documents
## Architecture
### 1. History Store (`src/stores/historyStore.ts`)
The central store manages history for all documents:
```typescript
{
histories: Map<documentId, DocumentHistory>
maxHistorySize: 50
}
```
Each `DocumentHistory` contains:
- `undoStack`: Array of past actions (most recent at end)
- `redoStack`: Array of undone actions that can be redone
- `currentState`: The current document state snapshot
**Key Methods:**
- `pushAction(documentId, action)`: Records a new action
- `undo(documentId)`: Reverts to previous state
- `redo(documentId)`: Restores undone state
- `canUndo/canRedo(documentId)`: Check if actions available
- `initializeHistory(documentId, initialState)`: Setup history for new document
- `removeHistory(documentId)`: Clean up when document deleted
### 2. Document History Hook (`src/hooks/useDocumentHistory.ts`)
Provides high-level undo/redo functionality for the active document:
```typescript
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription, pushToHistory } = useDocumentHistory();
```
**Responsibilities:**
- Initializes history when document is first loaded
- Provides `pushToHistory(description)` to record actions
- Handles undo/redo by restoring document state
- Updates both graphStore and workspaceStore on undo/redo
- Marks documents as dirty after undo/redo
### 3. Graph Operations with History (`src/hooks/useGraphWithHistory.ts`)
**OPTIONAL WRAPPER**: This hook wraps all graph operations with automatic history tracking.
```typescript
const { addNode, updateNode, deleteNode, addEdge, ... } = useGraphWithHistory();
```
Features:
- Debounces node position changes (500ms) to avoid cluttering history during dragging
- Immediate history push for add/delete operations
- Smart action descriptions (e.g., "Add Person Actor", "Delete Collaborates Relation")
- Prevents recursive history pushes during undo/redo restore
**Note:** This is an alternative to manually calling `pushToHistory()` after each operation.
### 4. Keyboard Shortcuts (`src/hooks/useKeyboardShortcuts.ts`)
Extended to support undo/redo:
```typescript
useKeyboardShortcuts({
onUndo: undo,
onRedo: redo,
// ... other shortcuts
});
```
Handles:
- `Ctrl+Z` / `Cmd+Z`: Undo
- `Ctrl+Y` / `Cmd+Y`: Redo
- `Ctrl+Shift+Z` / `Cmd+Shift+Z`: Alternative redo
### 5. Toolbar UI (`src/components/Toolbar/Toolbar.tsx`)
Displays undo/redo buttons with visual feedback:
- **Undo Button**: Shows "Undo: [action description] (Ctrl+Z)" on hover
- **Redo Button**: Shows "Redo: [action description] (Ctrl+Y)" on hover
- Buttons are disabled (grayed out) when no actions available
- Uses Material-UI icons (UndoIcon, RedoIcon)
### 6. App Integration (`src/App.tsx`)
Connects keyboard shortcuts to undo/redo functionality:
```typescript
const { undo, redo } = useDocumentHistory();
useKeyboardShortcuts({
onUndo: undo,
onRedo: redo,
});
```
## Usage
### Option A: Manual History Tracking
Components can manually record actions:
```typescript
import { useDocumentHistory } from '../hooks/useDocumentHistory';
function MyComponent() {
const { pushToHistory } = useDocumentHistory();
const graphStore = useGraphStore();
const handleAddNode = () => {
graphStore.addNode(newNode);
pushToHistory('Add Person Actor');
};
}
```
### Option B: Automatic with useGraphWithHistory
Replace `useGraphStore()` with `useGraphWithHistory()`:
```typescript
import { useGraphWithHistory } from '../hooks/useGraphWithHistory';
function MyComponent() {
const { addNode } = useGraphWithHistory();
const handleAddNode = () => {
addNode(newNode); // Automatically tracked!
};
}
```
### Current Implementation
The current codebase uses **Option A** (manual tracking). Components like `GraphEditor` and `Toolbar` use `useGraphStore()` directly.
To enable automatic tracking, update components to use `useGraphWithHistory()` instead of `useGraphStore()`.
## How It Works: Undo/Redo Flow
### Recording an Action
1. User performs action (e.g., adds a node)
2. `pushToHistory('Add Person Actor')` is called
3. Current document state is snapshotted
4. Snapshot is pushed to `undoStack`
5. `redoStack` is cleared (since new action invalidates redo)
### Performing Undo
1. User presses Ctrl+Z or clicks Undo button
2. Last action is popped from `undoStack`
3. Current state is pushed to `redoStack`
4. Previous state from action is restored
5. GraphStore and WorkspaceStore are updated
6. Document marked as dirty
### Performing Redo
1. User presses Ctrl+Y or clicks Redo button
2. Last undone action is popped from `redoStack`
3. Current state is pushed to `undoStack`
4. Future state from undone action is restored
5. GraphStore and WorkspaceStore are updated
6. Document marked as dirty
## Per-Document Independence
**Critical Feature:** Each document has completely separate history.
Example workflow:
1. Document A: Add 3 nodes
2. Switch to Document B: Add 2 edges
3. Switch back to Document A: Can still undo those 3 node additions
4. Switch back to Document B: Can still undo those 2 edge additions
History stacks are **preserved** across document switches and **remain independent**.
## Performance Considerations
### Memory Management
- Max 50 actions per document (configurable via `MAX_HISTORY_SIZE`)
- Old actions are automatically removed when limit exceeded
- History is removed when document is deleted
- Document states use deep cloning to prevent mutation issues
### Debouncing
- Node position updates are debounced (500ms) to group multiple moves
- Add/delete operations are immediate (0ms delay)
- Prevents hundreds of history entries when dragging nodes
## Testing Checklist
- [x] Create history store
- [x] Create useDocumentHistory hook
- [x] Add keyboard shortcuts (Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z)
- [x] Add undo/redo buttons to toolbar
- [x] Show action descriptions in tooltips
- [x] Disable buttons when no actions available
- [ ] Test: Add node → Undo → Node disappears
- [ ] Test: Delete node → Undo → Node reappears with connections
- [ ] Test: Move node → Undo → Node returns to original position
- [ ] Test: Add edge → Undo → Edge disappears
- [ ] Test: Update node properties → Undo → Properties restored
- [ ] Test: Multiple operations → Undo multiple times → Redo multiple times
- [ ] Test: Document A changes → Switch to Document B → Changes independent
- [ ] Test: 51 actions → Oldest action removed from history
- [ ] Test: Undo then new action → Redo stack cleared
- [ ] Test: Keyboard shortcuts work (Ctrl+Z, Ctrl+Y)
## Future Enhancements
1. **History Panel**: Show list of all actions with ability to jump to specific point
2. **Persistent History**: Save history to localStorage (survives page refresh)
3. **Collaborative Undo**: Undo operations in multi-user scenarios
4. **Selective Undo**: Undo specific actions, not just chronological
5. **History Branching**: Tree-based history (like Git) instead of linear
6. **Action Grouping**: Combine related actions (e.g., "Add 5 nodes" instead of 5 separate entries)
7. **Undo Metadata**: Store viewport position, selection state with each action
8. **History Analytics**: Track most common actions, undo patterns
## Implementation Notes
### Why Deep Cloning?
Document states are deep cloned using `JSON.parse(JSON.stringify())` to prevent mutation:
```typescript
const snapshot = JSON.parse(JSON.stringify(currentDoc));
```
This ensures that modifying the current state doesn't affect historical snapshots.
### Why Separate undoStack and redoStack?
Standard undo/redo pattern:
- **undoStack**: Stores past states
- **redoStack**: Stores undone states that can be restored
When a new action occurs, redoStack is cleared because the "future" is no longer valid.
### Why Per-Document History?
Users expect each document to maintain independent history, similar to:
- Text editors (each file has own undo stack)
- Image editors (each image has own history)
- IDEs (each file has own history)
This matches user mental model and prevents confusion.
## File Structure
```
src/
├── stores/
│ └── historyStore.ts # Central history management
├── hooks/
│ ├── useDocumentHistory.ts # Per-document undo/redo
│ ├── useGraphWithHistory.ts # Automatic history tracking wrapper
│ └── useKeyboardShortcuts.ts # Keyboard shortcuts (extended)
├── components/
│ └── Toolbar/
│ └── Toolbar.tsx # UI buttons for undo/redo
└── App.tsx # Connects keyboard shortcuts
```
## Conclusion
The undo/redo system provides a safety net for users, encouraging experimentation without fear of permanent mistakes. Each document maintains independent history, operations are automatically tracked, and the UI provides clear feedback about available undo/redo actions.
**Status:** ✅ Implementation Complete (Ready for Testing)
---
**Implemented:** 2025-10-09
**Based on:** UX_ANALYSIS.md recommendations (Priority: CRITICAL)

1469
docs/UX_ANALYSIS.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,152 @@
# Workspace Persistence Architecture
## Overview
The workspace manager now functions as a **persistent document library**. Documents are stored permanently in localStorage and remain available even after their tabs are closed.
## Key Concepts
### Document States
1. **Created Documents**: All documents created, imported, or opened are stored persistently in localStorage
2. **Open Documents**: Documents with active tabs (tracked in `documentOrder`)
3. **Closed Documents**: Documents stored in localStorage but without active tabs
### Data Structure
```typescript
// Workspace State (saved to localStorage)
{
workspaceId: string;
workspaceName: string;
documentOrder: string[]; // IDs of documents with open tabs
activeDocumentId: string | null; // Currently visible document
settings: WorkspaceSettings;
}
// Document Metadata (lightweight, one per document)
{
id: string;
title: string;
isDirty: boolean;
lastModified: string;
viewport?: { x, y, zoom }; // Persisted viewport state
}
// In-Memory State
{
documents: Map<string, ConstellationDocument>; // Loaded documents (performance optimization)
documentMetadata: Map<string, DocumentMetadata>; // All document metadata
}
```
## User Flows
### Creating a Document
1. User clicks "New Document"
2. Document is created and saved to localStorage
3. Document ID is added to `documentOrder` (opens as tab)
4. Document metadata is added to `documentMetadata`
5. Document is loaded into memory (`documents` Map)
### Closing a Tab
1. User closes a document tab (X button)
2. `closeDocument()` is called
3. Document ID is **removed from `documentOrder`** (tab disappears)
4. Document **remains in localStorage** (persistent storage)
5. Document metadata **remains in `documentMetadata`**
6. Document is unloaded from memory (performance optimization)
### Opening a Closed Document
1. User opens Document Manager
2. All documents from `documentMetadata` are displayed (including closed ones)
3. Closed documents are visually indicated (no "Open" badge)
4. User clicks on a closed document
5. `switchToDocument()` is called
6. Document is loaded from localStorage into memory
7. Document ID is **added back to `documentOrder`** (tab appears)
8. Document becomes active
### Deleting a Document
1. User clicks Delete in Document Manager
2. Confirmation dialog appears
3. If confirmed, `deleteDocument()` is called
4. Document is **removed from localStorage** (permanent deletion)
5. Document is removed from `documentOrder` (if open)
6. Document metadata is removed from `documentMetadata`
7. Document is unloaded from memory
## Implementation Details
### Key Functions
#### `closeDocument(documentId)`
- Removes from `documentOrder` (closes tab)
- Keeps in localStorage (persistent)
- Checks for unsaved changes before closing
#### `switchToDocument(documentId)`
- Loads document from localStorage if not in memory
- Adds to `documentOrder` if not already there (reopens tab)
- Sets as `activeDocumentId`
#### `deleteDocument(documentId)`
- Permanently removes from localStorage
- Removes from `documentOrder`
- Removes from `documentMetadata`
- Requires confirmation
### Document Manager Display
- Shows **all documents** from `documentMetadata` (not just `documentOrder`)
- Visual indicators:
- **Blue border + "Open" badge**: Document has an active tab
- **Orange dot**: Document has unsaved changes
- **Search**: Filters across all documents
- Footer shows: "X documents in workspace • Y open"
### Performance Optimizations
- **Lazy Loading**: Documents are only loaded into memory when needed
- **Unload**: Closed documents are removed from memory (but stay in storage)
- **Viewport Persistence**: Each document's viewport state is saved and restored
### History Management
- History stacks are per-document but **not persisted** to localStorage
- History is reset when a document is closed and reopened
- This is intentional to avoid localStorage bloat
## Storage Keys
```typescript
// Workspace state
'constellation:workspace:v1'
// Individual document
'constellation:document:v1:{documentId}'
// Document metadata
'constellation:meta:v1:{documentId}'
```
## Migration Notes
- Old single-document format is automatically migrated on first load
- Migration creates a workspace with the legacy document as the first document
- Migration is one-way (cannot downgrade)
## Future Enhancements
Potential improvements for future versions:
1. **Recent Documents List**: Show recently accessed documents separately
2. **Favorites**: Star/pin frequently used documents
3. **Document Tags**: Categorize documents with user-defined tags
4. **Trash/Archive**: Soft delete with recovery option
5. **Cloud Sync**: Synchronize workspace across devices
6. **History Persistence**: Optionally save undo/redo stacks (with size limits)

13
index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Constellation Analyzer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5229
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

38
package.json Normal file
View file

@ -0,0 +1,38 @@
{
"name": "constellation-analyzer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"jszip": "^3.10.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"reactflow": "^11.11.0",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.2.2",
"vite": "^5.1.0"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

26
public/favicon.svg Normal file
View file

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Background circle -->
<circle cx="32" cy="32" r="32" fill="#3b82f6"/>
<!-- Constellation pattern with nodes and connections -->
<!-- Connection lines (edges) -->
<line x1="20" y1="20" x2="32" y2="32" stroke="#ffffff" stroke-width="2" opacity="0.6"/>
<line x1="32" y1="32" x2="44" y2="20" stroke="#ffffff" stroke-width="2" opacity="0.6"/>
<line x1="32" y1="32" x2="44" y2="44" stroke="#ffffff" stroke-width="2" opacity="0.6"/>
<line x1="32" y1="32" x2="20" y2="44" stroke="#ffffff" stroke-width="2" opacity="0.6"/>
<line x1="20" y1="20" x2="44" y2="20" stroke="#ffffff" stroke-width="1.5" opacity="0.4"/>
<line x1="44" y1="20" x2="44" y2="44" stroke="#ffffff" stroke-width="1.5" opacity="0.4"/>
<!-- Nodes (actors) -->
<circle cx="20" cy="20" r="4" fill="#ffffff"/>
<circle cx="44" cy="20" r="4" fill="#ffffff"/>
<circle cx="32" cy="32" r="5" fill="#fbbf24"/>
<circle cx="20" cy="44" r="4" fill="#ffffff"/>
<circle cx="44" cy="44" r="4" fill="#ffffff"/>
<!-- Glow effect on center node -->
<circle cx="32" cy="32" r="5" fill="none" stroke="#fbbf24" stroke-width="1" opacity="0.5">
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;0.2;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

130
src/App.tsx Normal file
View file

@ -0,0 +1,130 @@
import { useState, useCallback, useEffect } from 'react';
import { ReactFlowProvider, useReactFlow } from 'reactflow';
import GraphEditor from './components/Editor/GraphEditor';
import Toolbar from './components/Toolbar/Toolbar';
import DocumentTabs from './components/Workspace/DocumentTabs';
import MenuBar from './components/Menu/MenuBar';
import DocumentManager from './components/Workspace/DocumentManager';
import KeyboardShortcutsHelp from './components/Common/KeyboardShortcutsHelp';
import { KeyboardShortcutProvider } from './contexts/KeyboardShortcutContext';
import { useGlobalShortcuts } from './hooks/useGlobalShortcuts';
import { useDocumentHistory } from './hooks/useDocumentHistory';
import { useWorkspaceStore } from './stores/workspaceStore';
/**
* App - Root application component
*
* Layout:
* - Header with title
* - Menu bar (File, Edit, View)
* - Document tabs for multi-file support
* - Toolbar for graph editing controls
* - Main graph editor canvas
*
* Features:
* - Responsive layout
* - ReactFlowProvider wrapper for graph functionality
* - Multi-document workspace with tabs
* - Organized menu system for file and editing operations
* - Per-document undo/redo with keyboard shortcuts
* - Centralized keyboard shortcut management system
*/
/** Inner component that has access to ReactFlow context */
function AppContent() {
const { undo, redo } = useDocumentHistory();
const { activeDocumentId } = useWorkspaceStore();
const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const { fitView } = useReactFlow();
// Listen for document manager open event from EmptyState
useEffect(() => {
const handleOpenDocumentManager = () => {
setShowDocumentManager(true);
};
window.addEventListener('openDocumentManager', handleOpenDocumentManager);
return () => window.removeEventListener('openDocumentManager', handleOpenDocumentManager);
}, []);
const handleFitView = useCallback(() => {
fitView({ padding: 0.2, duration: 300 });
}, [fitView]);
const handleSelectAll = useCallback(() => {
// This will be implemented in GraphEditor
// For now, we'll just document it
console.log('Select All - to be implemented');
}, []);
// Setup global keyboard shortcuts
useGlobalShortcuts({
onUndo: undo,
onRedo: redo,
onOpenDocumentManager: () => setShowDocumentManager(true),
onOpenHelp: () => setShowKeyboardHelp(true),
onFitView: handleFitView,
onSelectAll: handleSelectAll,
});
return (
<div className="flex flex-col h-screen bg-gray-100">
{/* Header */}
<header className="bg-gradient-to-r from-blue-600 to-blue-700 text-white shadow-lg">
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<img src="/favicon.svg" alt="Constellation Analyzer Logo" className="w-10 h-10" />
<div>
<h1 className="text-2xl font-bold">Constellation Analyzer</h1>
<p className="text-sm text-blue-100 mt-1">
Visual editor for analyzing actors and their relationships
</p>
</div>
</div>
</div>
</header>
{/* Menu Bar */}
<MenuBar
onOpenHelp={() => setShowKeyboardHelp(true)}
onFitView={handleFitView}
onSelectAll={handleSelectAll}
/>
{/* Document Tabs */}
<DocumentTabs />
{/* Toolbar - only show when a document is active */}
{activeDocumentId && <Toolbar />}
{/* Main graph editor */}
<main className="flex-1 overflow-hidden">
<GraphEditor />
</main>
{/* Document Manager Modal */}
<DocumentManager
isOpen={showDocumentManager}
onClose={() => setShowDocumentManager(false)}
/>
{/* Keyboard Shortcuts Help Modal */}
<KeyboardShortcutsHelp
isOpen={showKeyboardHelp}
onClose={() => setShowKeyboardHelp(false)}
/>
</div>
);
}
function App() {
return (
<KeyboardShortcutProvider>
<ReactFlowProvider>
<AppContent />
</ReactFlowProvider>
</KeyboardShortcutProvider>
);
}
export default App;

View file

@ -0,0 +1,129 @@
import { useEffect } from 'react';
import WarningIcon from '@mui/icons-material/Warning';
import ErrorIcon from '@mui/icons-material/Error';
import HelpIcon from '@mui/icons-material/Help';
/**
* ConfirmDialog - Custom confirmation dialog
*
* A modal dialog component that replaces window.confirm with a styled UI
* matching the application's design system.
*
* Features:
* - Customizable title and message
* - Configurable button labels
* - Different severity levels (info, warning, danger)
* - Keyboard support (Enter to confirm, Escape to cancel)
* - Backdrop click to cancel
*/
export type ConfirmDialogSeverity = 'info' | 'warning' | 'danger';
interface Props {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
severity?: ConfirmDialogSeverity;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog = ({
isOpen,
title,
message,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
severity = 'warning',
onConfirm,
onCancel,
}: Props) => {
// Handle keyboard shortcuts
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
onConfirm();
} else if (e.key === 'Escape') {
e.preventDefault();
onCancel();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onConfirm, onCancel]);
if (!isOpen) return null;
// Severity-based styling
const severityConfig = {
info: {
icon: <HelpIcon className="text-blue-600" sx={{ fontSize: 48 }} />,
confirmClass: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500',
},
warning: {
icon: <WarningIcon className="text-yellow-600" sx={{ fontSize: 48 }} />,
confirmClass: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500',
},
danger: {
icon: <ErrorIcon className="text-red-600" sx={{ fontSize: 48 }} />,
confirmClass: 'bg-red-600 hover:bg-red-700 focus:ring-red-500',
},
};
const config = severityConfig[severity];
return (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={onCancel}
>
<div
className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Content */}
<div className="p-6">
<div className="flex items-start space-x-4">
{/* Icon */}
<div className="flex-shrink-0">{config.icon}</div>
{/* Text Content */}
<div className="flex-1 pt-1">
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{title}
</h3>
<p className="text-sm text-gray-600 whitespace-pre-wrap">
{message}
</p>
</div>
</div>
</div>
{/* Actions */}
<div className="px-6 py-4 bg-gray-50 rounded-b-lg flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 bg-white border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-white text-sm font-medium rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 ${config.confirmClass}`}
autoFocus
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View file

@ -0,0 +1,91 @@
import React from 'react';
import AddIcon from '@mui/icons-material/Add';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import DescriptionIcon from '@mui/icons-material/Description';
/**
* EmptyState Component
*
* Displayed when no document is open in the workspace.
* Provides helpful actions and guidance to get started.
*/
interface EmptyStateProps {
onNewDocument: () => void;
onOpenDocumentManager: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
onNewDocument,
onOpenDocumentManager,
}) => {
return (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="max-w-2xl mx-auto px-8 py-12 text-center">
{/* Icon */}
<div className="mb-8">
<div className="inline-flex items-center justify-center w-24 h-24 rounded-full bg-blue-100 text-blue-600">
<DescriptionIcon sx={{ fontSize: 48 }} />
</div>
</div>
{/* Title */}
<h2 className="text-3xl font-bold text-gray-800 mb-4">
No Document Open
</h2>
{/* Description */}
<p className="text-lg text-gray-600 mb-8 max-w-lg mx-auto">
Start your constellation analysis by creating a new document or opening an existing one.
</p>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
<button
onClick={onNewDocument}
className="flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors shadow-md hover:shadow-lg font-medium"
>
<AddIcon />
New Document
</button>
<button
onClick={onOpenDocumentManager}
className="flex items-center justify-center gap-2 px-6 py-3 bg-white text-gray-700 border-2 border-gray-300 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
<FolderOpenIcon />
Open Document
</button>
</div>
{/* Helpful Tips */}
<div className="mt-12 pt-8 border-t border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-4">
Quick Tips
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-left">
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="text-blue-600 font-semibold mb-2">Keyboard Shortcuts</div>
<div className="text-sm text-gray-600">
Press <kbd className="px-2 py-1 bg-gray-100 rounded text-xs font-mono">Ctrl+N</kbd> to create a new document
</div>
</div>
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="text-blue-600 font-semibold mb-2">Document Manager</div>
<div className="text-sm text-gray-600">
Press <kbd className="px-2 py-1 bg-gray-100 rounded text-xs font-mono">Ctrl+O</kbd> to open the document manager
</div>
</div>
<div className="bg-white rounded-lg p-4 shadow-sm">
<div className="text-blue-600 font-semibold mb-2">Get Help</div>
<div className="text-sm text-gray-600">
Press <kbd className="px-2 py-1 bg-gray-100 rounded text-xs font-mono">?</kbd> to see all keyboard shortcuts
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default EmptyState;

View file

@ -0,0 +1,96 @@
import React from 'react';
import CloseIcon from '@mui/icons-material/Close';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import { useKeyboardShortcuts } from '../../contexts/KeyboardShortcutContext';
import type { ShortcutCategory } from '../../hooks/useKeyboardShortcutManager';
/**
* KeyboardShortcutsHelp Component
*
* Modal displaying all available keyboard shortcuts grouped by category.
* Triggered by pressing '?' key.
*/
interface KeyboardShortcutsHelpProps {
isOpen: boolean;
onClose: () => void;
}
const KeyboardShortcutsHelp: React.FC<KeyboardShortcutsHelpProps> = ({
isOpen,
onClose,
}) => {
const { shortcuts } = useKeyboardShortcuts();
if (!isOpen) return null;
const categories: ShortcutCategory[] = [
'Document Management',
'Graph Editing',
'Selection',
'View',
'Navigation',
];
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 text-white px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<KeyboardIcon sx={{ fontSize: 28 }} />
<h2 className="text-2xl font-bold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="text-white hover:bg-white hover:bg-opacity-20 rounded-full p-1 transition-colors"
aria-label="Close"
>
<CloseIcon />
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-6">
{categories.map(category => {
const categoryShortcuts = shortcuts
.getShortcutsByCategory(category)
.filter(s => s.enabled !== false);
if (categoryShortcuts.length === 0) return null;
return (
<div key={category} className="mb-6 last:mb-0">
<h3 className="text-lg font-semibold text-gray-800 mb-3 pb-2 border-b border-gray-200">
{category}
</h3>
<div className="space-y-2">
{categoryShortcuts.map(shortcut => (
<div
key={shortcut.id}
className="flex items-center justify-between py-2 hover:bg-gray-50 px-2 rounded"
>
<span className="text-gray-700">{shortcut.description}</span>
<kbd className="px-3 py-1.5 text-sm font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded shadow-sm min-w-[80px] text-center">
{shortcuts.formatShortcut(shortcut)}
</kbd>
</div>
))}
</div>
</div>
);
})}
</div>
{/* Footer */}
<div className="border-t border-gray-200 px-6 py-4 bg-gray-50">
<p className="text-sm text-gray-600">
Press <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-white border border-gray-300 rounded">?</kbd> or <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-white border border-gray-300 rounded">Escape</kbd> to close this dialog
</p>
</div>
</div>
</div>
);
};
export default KeyboardShortcutsHelp;

View file

@ -0,0 +1,97 @@
import { ReactNode } from 'react';
import { useConfirm } from '../../hooks/useConfirm';
/**
* PropertyPanel - Base component for property editing panels
*
* Features:
* - Consistent layout and styling
* - Header with title and close button
* - Content area for custom fields (via children)
* - Action buttons (Save & Delete)
* - Positioned absolutely for overlay display
*
* Usage: Wrap custom form fields as children
*/
interface Props {
isOpen: boolean;
title: string;
onClose: () => void;
onSave: () => void;
onDelete: () => void;
deleteConfirmMessage?: string;
deleteButtonLabel?: string;
children: ReactNode;
}
const PropertyPanel = ({
isOpen,
title,
onClose,
onSave,
onDelete,
deleteConfirmMessage = 'Are you sure you want to delete this item?',
deleteButtonLabel = 'Delete',
children,
}: Props) => {
const { confirm, ConfirmDialogComponent } = useConfirm();
if (!isOpen) return null;
const handleDelete = async () => {
const confirmed = await confirm({
title: 'Confirm Deletion',
message: deleteConfirmMessage,
confirmLabel: deleteButtonLabel,
severity: 'danger',
});
if (confirmed) {
onDelete();
}
};
return (
<div className="absolute top-20 right-4 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-40">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close panel"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content - custom fields provided by children */}
<div className="p-4 space-y-4">
{children}
</div>
{/* Actions */}
<div className="px-4 py-3 border-t border-gray-200 flex space-x-2">
<button
onClick={onSave}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500"
>
Save Changes
</button>
<button
onClick={handleDelete}
className="px-4 py-2 bg-red-50 text-red-700 text-sm font-medium rounded-md hover:bg-red-100 transition-colors focus:outline-none focus:ring-2 focus:ring-red-500"
>
{deleteButtonLabel}
</button>
</div>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</div>
);
};
export default PropertyPanel;

View file

@ -0,0 +1,241 @@
import { useState } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import EdgeTypeForm from './EdgeTypeForm';
import { useConfirm } from '../../hooks/useConfirm';
import type { EdgeTypeConfig } from '../../types';
/**
* EdgeTypeConfig - Modal for managing relation/edge types
*
* Features:
* - Add new edge types with custom name, color, and style
* - Edit existing edge types
* - Delete edge types
* - Style selector (solid, dashed, dotted)
*/
interface Props {
isOpen: boolean;
onClose: () => void;
}
const EdgeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const { edgeTypes, addEdgeType, updateEdgeType, deleteEdgeType } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
const [newTypeName, setNewTypeName] = useState('');
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
const [newTypeStyle, setNewTypeStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
// Editing state
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState('');
const [editStyle, setEditStyle] = useState<'solid' | 'dashed' | 'dotted'>('solid');
const handleAddType = () => {
if (!newTypeName.trim()) {
alert('Please enter a name for the relation type');
return;
}
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
// Check if ID already exists
if (edgeTypes.some(et => et.id === id)) {
alert('A relation type with this name already exists');
return;
}
const newType: EdgeTypeConfig = {
id,
label: newTypeName.trim(),
color: newTypeColor,
style: newTypeStyle,
};
addEdgeType(newType);
// Reset form
setNewTypeName('');
setNewTypeColor('#6366f1');
setNewTypeStyle('solid');
};
const handleDeleteType = async (id: string) => {
const confirmed = await confirm({
title: 'Delete Relation Type',
message: 'Are you sure you want to delete this relation type? This action cannot be undone.',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteEdgeType(id);
}
};
const handleEditType = (type: EdgeTypeConfig) => {
setEditingId(type.id);
setEditLabel(type.label);
setEditColor(type.color);
setEditStyle(type.style || 'solid');
};
const handleSaveEdit = () => {
if (!editingId || !editLabel.trim()) return;
updateEdgeType(editingId, {
label: editLabel.trim(),
color: editColor,
style: editStyle,
});
setEditingId(null);
};
const handleCancelEdit = () => {
setEditingId(null);
};
const renderStylePreview = (style: 'solid' | 'dashed' | 'dotted', color: string) => {
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[style];
return (
<svg width="100%" height="20" className="mt-1">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={color}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Configure Relation Types</h2>
<p className="text-sm text-gray-600 mt-1">
Customize the types of relations that can connect actors
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Add New Type Form */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Relation Type</h3>
<EdgeTypeForm
name={newTypeName}
color={newTypeColor}
style={newTypeStyle}
onNameChange={setNewTypeName}
onColorChange={setNewTypeColor}
onStyleChange={setNewTypeStyle}
/>
<button
onClick={handleAddType}
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Relation Type
</button>
</div>
{/* Existing Types List */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Relation Types</h3>
<div className="space-y-2">
{edgeTypes.map((type) => (
<div
key={type.id}
className="border border-gray-200 rounded-md overflow-hidden"
>
{editingId === type.id ? (
// Edit mode
<div className="bg-blue-50 p-4">
<EdgeTypeForm
name={editLabel}
color={editColor}
style={editStyle}
onNameChange={setEditLabel}
onColorChange={setEditColor}
onStyleChange={setEditStyle}
/>
<div className="flex space-x-2 mt-3">
<button
onClick={handleSaveEdit}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 mb-1">
{type.label}
</div>
<div className="w-full max-w-xs">
{renderStylePreview(type.style || 'solid', type.color)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditType(type)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDeleteType(type.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</div>
);
};
export default EdgeTypeConfigModal;

View file

@ -0,0 +1,105 @@
/**
* EdgeTypeForm - Reusable form fields for creating/editing edge types
*
* Features:
* - Name input
* - Color picker (visual + text input)
* - Line style selector (solid/dashed/dotted)
* - Visual style preview
*/
interface Props {
name: string;
color: string;
style: 'solid' | 'dashed' | 'dotted';
onNameChange: (value: string) => void;
onColorChange: (value: string) => void;
onStyleChange: (value: 'solid' | 'dashed' | 'dotted') => void;
}
const EdgeTypeForm = ({
name,
color,
style,
onNameChange,
onColorChange,
onStyleChange,
}: Props) => {
const renderStylePreview = (lineStyle: 'solid' | 'dashed' | 'dotted', lineColor: string) => {
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[lineStyle];
return (
<svg width="100%" height="20" className="mt-1">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={lineColor}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Supervises, Communicates With"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Color *
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="h-10 w-20 rounded cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => onColorChange(e.target.value)}
placeholder="#6366f1"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Line Style *
</label>
<select
value={style}
onChange={(e) => onStyleChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
</select>
{renderStylePreview(style, color)}
</div>
</div>
);
};
export default EdgeTypeForm;

View file

@ -0,0 +1,109 @@
import PersonIcon from '@mui/icons-material/Person';
import GroupIcon from '@mui/icons-material/Group';
import BusinessIcon from '@mui/icons-material/Business';
import ComputerIcon from '@mui/icons-material/Computer';
import CloudIcon from '@mui/icons-material/Cloud';
import StorageIcon from '@mui/icons-material/Storage';
import DevicesIcon from '@mui/icons-material/Devices';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import CategoryIcon from '@mui/icons-material/Category';
import LightbulbIcon from '@mui/icons-material/Lightbulb';
import WorkIcon from '@mui/icons-material/Work';
import SchoolIcon from '@mui/icons-material/School';
import LocalHospitalIcon from '@mui/icons-material/LocalHospital';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import StoreIcon from '@mui/icons-material/Store';
import FactoryIcon from '@mui/icons-material/Factory';
import EngineeringIcon from '@mui/icons-material/Engineering';
import ScienceIcon from '@mui/icons-material/Science';
import PublicIcon from '@mui/icons-material/Public';
import LocationCityIcon from '@mui/icons-material/LocationCity';
/**
* IconSelector - Icon picker component for selecting Material Design icons
*
* Features:
* - Grid display of available icons
* - Visual selection feedback
* - Returns selected icon name
*/
interface Props {
selectedIcon?: string;
onSelect: (iconName: string) => void;
}
// Available icons with their names
const availableIcons = [
{ name: 'Person', component: PersonIcon },
{ name: 'Group', component: GroupIcon },
{ name: 'Business', component: BusinessIcon },
{ name: 'Computer', component: ComputerIcon },
{ name: 'Cloud', component: CloudIcon },
{ name: 'Storage', component: StorageIcon },
{ name: 'Devices', component: DevicesIcon },
{ name: 'AccountTree', component: AccountTreeIcon },
{ name: 'Category', component: CategoryIcon },
{ name: 'Lightbulb', component: LightbulbIcon },
{ name: 'Work', component: WorkIcon },
{ name: 'School', component: SchoolIcon },
{ name: 'LocalHospital', component: LocalHospitalIcon },
{ name: 'AccountBalance', component: AccountBalanceIcon },
{ name: 'Store', component: StoreIcon },
{ name: 'Factory', component: FactoryIcon },
{ name: 'Engineering', component: EngineeringIcon },
{ name: 'Science', component: ScienceIcon },
{ name: 'Public', component: PublicIcon },
{ name: 'LocationCity', component: LocationCityIcon },
];
const IconSelector = ({ selectedIcon, onSelect }: Props) => {
return (
<div>
<label className="block text-xs font-medium text-gray-700 mb-2">
Icon (optional)
</label>
<div className="grid grid-cols-8 gap-2 max-h-48 overflow-y-auto border border-gray-200 rounded-md p-2">
{/* No icon option */}
<button
type="button"
onClick={() => onSelect('')}
className={`
p-2 rounded border-2 transition-all flex items-center justify-center
${!selectedIcon
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
title="No icon"
>
<span className="text-xs text-gray-500"></span>
</button>
{/* Icon options */}
{availableIcons.map((icon) => {
const IconComponent = icon.component;
return (
<button
key={icon.name}
type="button"
onClick={() => onSelect(icon.name)}
className={`
p-2 rounded border-2 transition-all flex items-center justify-center
${selectedIcon === icon.name
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
title={icon.name}
>
<IconComponent fontSize="small" className="text-gray-700" />
</button>
);
})}
</div>
</div>
);
};
export default IconSelector;

View file

@ -0,0 +1,235 @@
import { useState } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import NodeTypeForm from './NodeTypeForm';
import { useConfirm } from '../../hooks/useConfirm';
import type { NodeTypeConfig } from '../../types';
/**
* NodeTypeConfig - Modal for managing actor/node types
*
* Features:
* - Add new node types with custom name and color
* - Edit existing node types
* - Delete node types
* - Color picker for visual customization
*/
interface Props {
isOpen: boolean;
onClose: () => void;
}
const NodeTypeConfigModal = ({ isOpen, onClose }: Props) => {
const { nodeTypes, addNodeType, updateNodeType, deleteNodeType } = useGraphWithHistory();
const { confirm, ConfirmDialogComponent } = useConfirm();
const [newTypeName, setNewTypeName] = useState('');
const [newTypeColor, setNewTypeColor] = useState('#6366f1');
const [newTypeDescription, setNewTypeDescription] = useState('');
const [newTypeIcon, setNewTypeIcon] = useState('');
// Editing state
const [editingId, setEditingId] = useState<string | null>(null);
const [editLabel, setEditLabel] = useState('');
const [editColor, setEditColor] = useState('');
const [editIcon, setEditIcon] = useState('');
const [editDescription, setEditDescription] = useState('');
const handleAddType = () => {
if (!newTypeName.trim()) {
alert('Please enter a name for the node type');
return;
}
const id = newTypeName.toLowerCase().replace(/\s+/g, '-');
// Check if ID already exists
if (nodeTypes.some(nt => nt.id === id)) {
alert('A node type with this name already exists');
return;
}
const newType: NodeTypeConfig = {
id,
label: newTypeName.trim(),
color: newTypeColor,
icon: newTypeIcon || undefined,
description: newTypeDescription.trim() || undefined,
};
addNodeType(newType);
// Reset form
setNewTypeName('');
setNewTypeColor('#6366f1');
setNewTypeDescription('');
setNewTypeIcon('');
};
const handleDeleteType = async (id: string) => {
const confirmed = await confirm({
title: 'Delete Actor Type',
message: 'Are you sure you want to delete this actor type? This action cannot be undone.',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteNodeType(id);
}
};
const handleEditType = (type: NodeTypeConfig) => {
setEditingId(type.id);
setEditLabel(type.label);
setEditColor(type.color);
setEditIcon(type.icon || '');
setEditDescription(type.description || '');
};
const handleSaveEdit = () => {
if (!editingId || !editLabel.trim()) return;
updateNodeType(editingId, {
label: editLabel.trim(),
color: editColor,
icon: editIcon || undefined,
description: editDescription.trim() || undefined,
});
setEditingId(null);
};
const handleCancelEdit = () => {
setEditingId(null);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Configure Actor Types</h2>
<p className="text-sm text-gray-600 mt-1">
Customize the types of actors that can be added to your constellation
</p>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6">
{/* Add New Type Form */}
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Add New Actor Type</h3>
<NodeTypeForm
name={newTypeName}
color={newTypeColor}
icon={newTypeIcon}
description={newTypeDescription}
onNameChange={setNewTypeName}
onColorChange={setNewTypeColor}
onIconChange={setNewTypeIcon}
onDescriptionChange={setNewTypeDescription}
/>
<button
onClick={handleAddType}
className="w-full mt-3 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Add Actor Type
</button>
</div>
{/* Existing Types List */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Existing Actor Types</h3>
<div className="space-y-2">
{nodeTypes.map((type) => (
<div
key={type.id}
className="border border-gray-200 rounded-md overflow-hidden"
>
{editingId === type.id ? (
// Edit mode
<div className="bg-blue-50 p-4">
<NodeTypeForm
name={editLabel}
color={editColor}
icon={editIcon}
description={editDescription}
onNameChange={setEditLabel}
onColorChange={setEditColor}
onIconChange={setEditIcon}
onDescriptionChange={setEditDescription}
/>
<div className="flex space-x-2 mt-3">
<button
onClick={handleSaveEdit}
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
>
Save
</button>
<button
onClick={handleCancelEdit}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
// View mode
<div className="flex items-center justify-between p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center space-x-3 flex-1">
<div
className="w-8 h-8 rounded"
style={{ backgroundColor: type.color }}
/>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900">
{type.label}
</div>
{type.description && (
<div className="text-xs text-gray-500">{type.description}</div>
)}
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleEditType(type)}
className="px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDeleteType(type.id)}
className="px-3 py-1 text-sm text-red-600 hover:bg-red-50 rounded transition-colors"
>
Delete
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 text-gray-800 text-sm font-medium rounded-md hover:bg-gray-300 transition-colors"
>
Close
</button>
</div>
</div>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</div>
);
};
export default NodeTypeConfigModal;

View file

@ -0,0 +1,88 @@
import IconSelector from './IconSelector';
/**
* NodeTypeForm - Reusable form fields for creating/editing node types
*
* Features:
* - Name input
* - Color picker (visual + text input)
* - Icon selector
* - Description input
*/
interface Props {
name: string;
color: string;
icon: string;
description: string;
onNameChange: (value: string) => void;
onColorChange: (value: string) => void;
onIconChange: (value: string) => void;
onDescriptionChange: (value: string) => void;
}
const NodeTypeForm = ({
name,
color,
icon,
description,
onNameChange,
onColorChange,
onIconChange,
onDescriptionChange,
}: Props) => {
return (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g., Department, Role, Team"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Color *
</label>
<div className="flex items-center space-x-2">
<input
type="color"
value={color}
onChange={(e) => onColorChange(e.target.value)}
className="h-10 w-20 rounded cursor-pointer"
/>
<input
type="text"
value={color}
onChange={(e) => onColorChange(e.target.value)}
placeholder="#6366f1"
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<IconSelector selectedIcon={icon} onSelect={onIconChange} />
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Description (optional)
</label>
<input
type="text"
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
placeholder="Brief description of this actor type"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
);
};
export default NodeTypeForm;

View file

@ -0,0 +1,91 @@
import { memo } from 'react';
import {
EdgeProps,
getBezierPath,
EdgeLabelRenderer,
BaseEdge,
} from 'reactflow';
import { useGraphStore } from '../../stores/graphStore';
import type { RelationData } from '../../types';
/**
* CustomEdge - Represents a relation between actors in the constellation graph
*
* Features:
* - Bezier curve path
* - Type-based coloring and styling
* - Optional label display
* - Edge type badge
*
* Usage: Automatically rendered by React Flow for edges with type='custom'
*/
const CustomEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
selected,
}: EdgeProps<RelationData>) => {
const edgeTypes = useGraphStore((state) => state.edgeTypes);
// Calculate the bezier path
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
// Find the edge type configuration
const edgeTypeConfig = edgeTypes.find((et) => et.id === data?.type);
const edgeColor = edgeTypeConfig?.color || '#6b7280';
const edgeStyle = edgeTypeConfig?.style || 'solid';
// Use custom label if provided, otherwise use type's default label
const displayLabel = data?.label || edgeTypeConfig?.label;
// Convert style to stroke-dasharray
const strokeDasharray = {
solid: '0',
dashed: '5,5',
dotted: '1,5',
}[edgeStyle];
return (
<>
<BaseEdge
id={id}
path={edgePath}
style={{
stroke: edgeColor,
strokeWidth: selected ? 3 : 2,
strokeDasharray,
}}
/>
{/* Edge label - show custom or type default */}
{displayLabel && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
}}
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
>
<div style={{ color: edgeColor }}>{displayLabel}</div>
</div>
</EdgeLabelRenderer>
)}
</>
);
};
export default memo(CustomEdge);

View file

@ -0,0 +1,102 @@
import { useEffect, useRef } from 'react';
/**
* ContextMenu - Custom right-click menu for graph editor
*
* Features:
* - Positioned at click location
* - Closes on click outside or escape key
* - Supports nested menu items with icons
*/
interface MenuAction {
label: string;
icon?: React.ReactNode;
color?: string;
onClick: () => void;
}
interface MenuSection {
title?: string;
actions: MenuAction[];
}
interface Props {
x: number;
y: number;
sections: MenuSection[];
onClose: () => void;
}
const ContextMenu = ({ x, y, sections, onClose }: Props) => {
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
// Delay adding listeners to avoid immediate close from the triggering click
setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
}, 0);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [onClose]);
return (
<div
ref={menuRef}
className="fixed bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50 min-w-[200px]"
style={{ left: x, top: y }}
>
{sections.map((section, sectionIdx) => (
<div key={sectionIdx}>
{section.title && (
<div className="px-3 py-2 text-xs font-semibold text-gray-500 uppercase">
{section.title}
</div>
)}
{section.actions.map((action, actionIdx) => (
<button
key={actionIdx}
onClick={() => {
action.onClick();
onClose();
}}
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2 transition-colors"
>
{action.color && (
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: action.color }}
/>
)}
{action.icon && (
<span className="text-gray-600 flex-shrink-0 flex items-center">{action.icon}</span>
)}
<span className="flex-1">{action.label}</span>
</button>
))}
{sectionIdx < sections.length - 1 && (
<div className="h-px bg-gray-200 my-1" />
)}
</div>
))}
</div>
);
};
export default ContextMenu;

View file

@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel';
import type { Relation } from '../../types';
/**
* EdgePropertiesPanel - Side panel for editing edge/relation properties
*
* Features:
* - Change relation type
* - Edit relation label
* - Delete relation
* - Visual preview of line style
*/
interface Props {
selectedEdge: Relation | null;
onClose: () => void;
}
const EdgePropertiesPanel = ({ selectedEdge, onClose }: Props) => {
const { edgeTypes, updateEdge, deleteEdge } = useGraphWithHistory();
const [relationType, setRelationType] = useState('');
const [relationLabel, setRelationLabel] = useState('');
useEffect(() => {
if (selectedEdge && selectedEdge.data) {
setRelationType(selectedEdge.data.type || '');
// Only show custom label if it exists and differs from type label
const typeLabel = edgeTypes.find((et) => et.id === selectedEdge.data?.type)?.label;
const hasCustomLabel = selectedEdge.data.label && selectedEdge.data.label !== typeLabel;
setRelationLabel((hasCustomLabel && selectedEdge.data.label) || '');
}
}, [selectedEdge, edgeTypes]);
const handleSave = () => {
if (!selectedEdge) return;
updateEdge(selectedEdge.id, {
type: relationType,
// Only set label if user provided a custom one (not empty)
label: relationLabel.trim() || undefined,
});
onClose();
};
const handleDelete = () => {
if (!selectedEdge) return;
deleteEdge(selectedEdge.id);
onClose();
};
const selectedEdgeTypeConfig = edgeTypes.find((et) => et.id === relationType);
const renderStylePreview = () => {
if (!selectedEdgeTypeConfig) return null;
const strokeDasharray = {
solid: '0',
dashed: '8,4',
dotted: '2,4',
}[selectedEdgeTypeConfig.style || 'solid'];
return (
<svg width="100%" height="20" className="mt-2">
<line
x1="0"
y1="10"
x2="100%"
y2="10"
stroke={selectedEdgeTypeConfig.color}
strokeWidth="3"
strokeDasharray={strokeDasharray}
/>
</svg>
);
};
return (
<PropertyPanel
isOpen={!!selectedEdge}
title="Relation Properties"
onClose={onClose}
onSave={handleSave}
onDelete={handleDelete}
deleteConfirmMessage="Are you sure you want to delete this relation?"
deleteButtonLabel="Delete"
>
{/* Relation Type */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Relation Type
</label>
<select
value={relationType}
onChange={(e) => setRelationType(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{edgeTypes.map((edgeType) => (
<option key={edgeType.id} value={edgeType.id}>
{edgeType.label}
</option>
))}
</select>
{renderStylePreview()}
</div>
{/* Custom Label */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Custom Label (optional)
</label>
<input
type="text"
value={relationLabel}
onChange={(e) => setRelationLabel(e.target.value)}
placeholder={selectedEdgeTypeConfig?.label || 'Enter label'}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<p className="text-xs text-gray-500 mt-1">
Leave empty to use default type label
</p>
</div>
{/* Connection Info */}
{selectedEdge && (
<div className="pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
<span className="font-medium">From:</span> {selectedEdge.source}
</p>
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">To:</span> {selectedEdge.target}
</p>
</div>
)}
</PropertyPanel>
);
};
export default EdgePropertiesPanel;

View file

@ -0,0 +1,575 @@
import { useCallback, useMemo, useEffect, useState, useRef } from 'react';
import ReactFlow, {
Background,
Controls,
MiniMap,
Connection,
NodeTypes,
EdgeTypes,
BackgroundVariant,
useNodesState,
useEdgesState,
addEdge,
Node,
Edge,
NodeChange,
EdgeChange,
ConnectionMode,
useReactFlow,
Viewport,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { useEditorStore } from '../../stores/editorStore';
import { useActiveDocument } from '../../stores/workspace/useActiveDocument';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import CustomNode from '../Nodes/CustomNode';
import CustomEdge from '../Edges/CustomEdge';
import EdgePropertiesPanel from './EdgePropertiesPanel';
import NodePropertiesPanel from './NodePropertiesPanel';
import ContextMenu from './ContextMenu';
import EmptyState from '../Common/EmptyState';
import { createNode } from '../../utils/nodeUtils';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { useConfirm } from '../../hooks/useConfirm';
import type { Actor, Relation } from '../../types';
/**
* GraphEditor - Main interactive graph visualization component
*
* Features:
* - Interactive node dragging and positioning
* - Edge creation by dragging from node handles
* - Background grid
* - MiniMap for navigation
* - Zoom and pan controls
* - Synchronized with workspace active document
*
* Usage: Core component that wraps React Flow with custom nodes and edges
*/
const GraphEditor = () => {
// Sync with workspace active document
const { activeDocumentId } = useActiveDocument();
const { saveViewport, getViewport, createDocument } = useWorkspaceStore();
const {
nodes: storeNodes,
edges: storeEdges,
nodeTypes: nodeTypeConfigs,
edgeTypes: edgeTypeConfigs,
setNodes,
setEdges,
addEdge: addEdgeWithHistory,
addNode: addNodeWithHistory,
deleteNode,
deleteEdge,
} = useGraphWithHistory();
const { pushToHistory } = useDocumentHistory();
const { showGrid, snapToGrid, gridSize, panOnDrag, zoomOnScroll, selectedRelationType } =
useEditorStore();
// React Flow instance for screen-to-flow coordinates and viewport control
const { screenToFlowPosition, setViewport, getViewport: getCurrentViewport } = useReactFlow();
// Track previous document ID to save viewport before switching
const prevDocumentIdRef = useRef<string | null>(null);
// Confirmation dialog
const { confirm, ConfirmDialogComponent } = useConfirm();
// React Flow state (synchronized with store)
const [nodes, setNodesState, onNodesChange] = useNodesState(storeNodes as Node[]);
const [edges, setEdgesState, onEdgesChange] = useEdgesState(storeEdges as Edge[]);
// Track if a drag is in progress to capture state before drag
const dragInProgressRef = useRef(false);
// Selected edge/node state for properties panels
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
// Context menu state
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
type: 'pane' | 'node' | 'edge';
target?: Node | Edge;
} | null>(null);
// Sync store changes to React Flow state
useEffect(() => {
setNodesState(storeNodes as Node[]);
}, [storeNodes, setNodesState]);
useEffect(() => {
setEdgesState(storeEdges as Edge[]);
}, [storeEdges, setEdgesState]);
// Save viewport when switching documents and restore viewport for new document
useEffect(() => {
if (!activeDocumentId) return;
// Save viewport for the previous document
if (prevDocumentIdRef.current && prevDocumentIdRef.current !== activeDocumentId) {
const currentViewport = getCurrentViewport();
saveViewport(prevDocumentIdRef.current, currentViewport);
console.log(`Saved viewport for document: ${prevDocumentIdRef.current}`, currentViewport);
}
// Restore viewport for the new document
const savedViewport = getViewport(activeDocumentId);
if (savedViewport) {
console.log(`Restoring viewport for document: ${activeDocumentId}`, savedViewport);
setViewport(savedViewport, { duration: 0 });
}
// Update the ref to current document
prevDocumentIdRef.current = activeDocumentId;
}, [activeDocumentId, saveViewport, getViewport, setViewport, getCurrentViewport]);
// Save viewport periodically (debounced)
const handleViewportChange = useCallback(
(_event: MouseEvent | TouchEvent | null, viewport: Viewport) => {
if (!activeDocumentId) return;
// Debounce viewport saves
const timeoutId = setTimeout(() => {
saveViewport(activeDocumentId, viewport);
}, 500);
return () => clearTimeout(timeoutId);
},
[activeDocumentId, saveViewport]
);
// Sync React Flow state back to store when nodes/edges change
// IMPORTANT: This handler tracks drag operations for undo/redo
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
// Check if a drag operation just started (dragging: true)
const dragStartChanges = changes.filter(
(change) =>
change.type === 'position' &&
'dragging' in change &&
change.dragging === true
);
// Capture state BEFORE the drag operation begins (for undo/redo)
// This ensures we can restore to the position before dragging
if (dragStartChanges.length > 0 && !dragInProgressRef.current) {
dragInProgressRef.current = true;
// Capture the state before any changes are applied
pushToHistory('Move Actor');
}
// Apply the changes
onNodesChange(changes);
// Check if any drag operation just completed (dragging: false)
const dragEndChanges = changes.filter(
(change) =>
change.type === 'position' &&
'dragging' in change &&
change.dragging === false
);
// If a drag just ended, sync to store
if (dragEndChanges.length > 0) {
dragInProgressRef.current = false;
// Debounce to allow React Flow state to settle
setTimeout(() => {
// Sync to store
setNodes(nodes as Actor[]);
}, 0);
} else {
// For non-drag changes (dimension, etc), just sync to store
const hasNonSelectionChanges = changes.some(
(change) => change.type !== 'select' && change.type !== 'remove' && change.type !== 'position'
);
if (hasNonSelectionChanges) {
setTimeout(() => {
setNodes(nodes as Actor[]);
}, 0);
}
}
},
[onNodesChange, nodes, setNodes, pushToHistory]
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
onEdgesChange(changes);
// Only sync to store for non-selection changes
const hasNonSelectionChanges = changes.some(
(change) => change.type !== 'select' && change.type !== 'remove'
);
if (hasNonSelectionChanges) {
// Debounce store updates to avoid loops
setTimeout(() => {
setEdges(edges as Relation[]);
}, 0);
}
},
[onEdgesChange, edges, setEdges]
);
// Handle new edge connections
const handleConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target) return;
// Use selected relation type or fall back to first available
const edgeType = selectedRelationType || edgeTypeConfigs[0]?.id || 'default';
// Create edge with custom data (no label - will use type default)
const edgeWithData = {
...connection,
type: 'custom',
data: {
type: edgeType,
// Don't set label - will use type's label as default
},
};
// Use React Flow's addEdge helper to properly format the edge
const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]);
// Find the newly added edge (it will be the last one)
const newEdge = updatedEdges[updatedEdges.length - 1] as Relation;
// Use the history-tracked addEdge function
addEdgeWithHistory(newEdge);
},
[storeEdges, edgeTypeConfigs, addEdgeWithHistory, selectedRelationType]
);
// Handle node deletion
const handleNodesDelete = useCallback(
(nodesToDelete: Node[]) => {
nodesToDelete.forEach((node) => {
deleteNode(node.id);
});
},
[deleteNode]
);
// Handle edge deletion
const handleEdgesDelete = useCallback(
(edgesToDelete: Edge[]) => {
edgesToDelete.forEach((edge) => {
deleteEdge(edge.id);
});
},
[deleteEdge]
);
// Register custom node types
const nodeTypes: NodeTypes = useMemo(
() => ({
custom: CustomNode,
}),
[]
);
// Register custom edge types
const edgeTypes: EdgeTypes = useMemo(
() => ({
custom: CustomEdge,
}),
[]
);
// Handle edge double-click to show properties
const handleEdgeDoubleClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
setSelectedNode(null); // Close node panel if open
setSelectedEdge(edge as Relation);
setContextMenu(null); // Close context menu if open
},
[]
);
// Handle node double-click to show properties
const handleNodeDoubleClick = useCallback(
(_event: React.MouseEvent, node: Node) => {
setSelectedEdge(null); // Close edge panel if open
setSelectedNode(node as Actor);
setContextMenu(null); // Close context menu if open
},
[]
);
// Handle node click to close context menu
const handleNodeClick = useCallback(() => {
if (contextMenu) {
setContextMenu(null);
}
}, [contextMenu]);
// Handle edge click to close context menu
const handleEdgeClick = useCallback(() => {
if (contextMenu) {
setContextMenu(null);
}
}, [contextMenu]);
// Handle right-click on pane (empty space)
const handlePaneContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'pane',
});
},
[]
);
// Handle right-click on node
const handleNodeContextMenu = useCallback(
(event: React.MouseEvent, node: Node) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'node',
target: node,
});
},
[]
);
// Handle right-click on edge
const handleEdgeContextMenu = useCallback(
(event: React.MouseEvent, edge: Edge) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
type: 'edge',
target: edge,
});
},
[]
);
// Handle left-click on pane to close context menu
const handlePaneClick = useCallback(() => {
if (contextMenu) {
setContextMenu(null);
}
}, [contextMenu]);
// Add new actor at context menu position
const handleAddActorFromContextMenu = useCallback(
(nodeTypeId: string) => {
if (!contextMenu) return;
const position = screenToFlowPosition({
x: contextMenu.x,
y: contextMenu.y,
});
const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
// Use history-tracked addNode instead of setNodes
addNodeWithHistory(newNode);
setContextMenu(null);
},
[contextMenu, screenToFlowPosition, nodeTypeConfigs, addNodeWithHistory]
);
// Show empty state when no document is active
if (!activeDocumentId) {
return (
<EmptyState
onNewDocument={() => createDocument()}
onOpenDocumentManager={() => {
// This will be handled by the parent component
// We'll trigger it via a custom event
window.dispatchEvent(new CustomEvent('openDocumentManager'));
}}
/>
);
}
return (
<div className="w-full h-full bg-gray-50 relative">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onNodesDelete={handleNodesDelete}
onEdgesDelete={handleEdgesDelete}
onNodeClick={handleNodeClick}
onEdgeClick={handleEdgeClick}
onEdgeDoubleClick={handleEdgeDoubleClick}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onEdgeContextMenu={handleEdgeContextMenu}
onPaneContextMenu={handlePaneContextMenu}
onPaneClick={handlePaneClick}
onMove={handleViewportChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
connectionMode={ConnectionMode.Loose}
connectOnClick={true}
snapToGrid={snapToGrid}
snapGrid={[gridSize, gridSize]}
panOnDrag={panOnDrag}
zoomOnScroll={zoomOnScroll}
connectionRadius={0}
fitView
attributionPosition="bottom-left"
>
{/* Background grid */}
{showGrid && (
<Background
variant={BackgroundVariant.Dots}
gap={gridSize}
size={1}
color="#d1d5db"
/>
)}
{/* Zoom and pan controls */}
<Controls />
{/* MiniMap for navigation */}
<MiniMap
nodeColor={(node) => {
const actor = node as Actor;
const nodeType = nodeTypeConfigs.find(
(nt) => nt.id === actor.data?.type
);
return nodeType?.color || '#6b7280';
}}
pannable
zoomable
/>
</ReactFlow>
{/* Property Panels */}
<EdgePropertiesPanel
selectedEdge={selectedEdge}
onClose={() => setSelectedEdge(null)}
/>
<NodePropertiesPanel
selectedNode={selectedNode}
onClose={() => setSelectedNode(null)}
/>
{/* Context Menu - Pane */}
{contextMenu && contextMenu.type === 'pane' && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
sections={[
{
title: 'Add Actor',
actions: nodeTypeConfigs.map((nodeType) => ({
label: nodeType.label,
color: nodeType.color,
onClick: () => handleAddActorFromContextMenu(nodeType.id),
})),
},
]}
onClose={() => setContextMenu(null)}
/>
)}
{/* Context Menu - Node */}
{contextMenu && contextMenu.type === 'node' && contextMenu.target && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
sections={[
{
actions: [
{
label: 'Edit Properties',
icon: <EditIcon fontSize="small" />,
onClick: () => {
setSelectedNode(contextMenu.target as Actor);
setContextMenu(null);
},
},
{
label: 'Delete',
icon: <DeleteIcon fontSize="small" />,
onClick: async () => {
const confirmed = await confirm({
title: 'Delete Actor',
message: 'Are you sure you want to delete this actor? All connected relations will also be deleted.',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteNode(contextMenu.target!.id);
setContextMenu(null);
}
},
},
],
},
]}
onClose={() => setContextMenu(null)}
/>
)}
{/* Context Menu - Edge */}
{contextMenu && contextMenu.type === 'edge' && contextMenu.target && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
sections={[
{
actions: [
{
label: 'Edit Properties',
icon: <EditIcon fontSize="small" />,
onClick: () => {
setSelectedEdge(contextMenu.target as Relation);
setContextMenu(null);
},
},
{
label: 'Delete',
icon: <DeleteIcon fontSize="small" />,
onClick: async () => {
const confirmed = await confirm({
title: 'Delete Relation',
message: 'Are you sure you want to delete this relation?',
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteEdge(contextMenu.target!.id);
setContextMenu(null);
}
},
},
],
},
]}
onClose={() => setContextMenu(null)}
/>
)}
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</div>
);
};
export default GraphEditor;

View file

@ -0,0 +1,148 @@
import { useState, useEffect, useRef } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import PropertyPanel from '../Common/PropertyPanel';
import type { Actor } from '../../types';
/**
* NodePropertiesPanel - Side panel for editing node/actor properties
*
* Features:
* - Change actor type
* - Edit actor label
* - Edit description
* - Delete actor
* - Visual color preview
*/
interface Props {
selectedNode: Actor | null;
onClose: () => void;
}
const NodePropertiesPanel = ({ selectedNode, onClose }: Props) => {
const { nodeTypes, updateNode, deleteNode } = useGraphWithHistory();
const [actorType, setActorType] = useState('');
const [actorLabel, setActorLabel] = useState('');
const [actorDescription, setActorDescription] = useState('');
const labelInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedNode) {
setActorType(selectedNode.data?.type || '');
setActorLabel(selectedNode.data?.label || '');
setActorDescription(selectedNode.data?.description || '');
// Focus and select the label input when panel opens
setTimeout(() => {
if (labelInputRef.current) {
labelInputRef.current.focus();
labelInputRef.current.select();
}
}, 100); // Small delay to ensure panel animation completes
}
}, [selectedNode]);
const handleSave = () => {
if (!selectedNode) return;
updateNode(selectedNode.id, {
data: {
type: actorType,
label: actorLabel,
description: actorDescription || undefined,
},
});
onClose();
};
const handleDelete = () => {
if (!selectedNode) return;
deleteNode(selectedNode.id);
onClose();
};
const selectedNodeTypeConfig = nodeTypes.find((nt) => nt.id === actorType);
return (
<PropertyPanel
isOpen={!!selectedNode}
title="Actor Properties"
onClose={onClose}
onSave={handleSave}
onDelete={handleDelete}
deleteConfirmMessage="Are you sure you want to delete this actor? All connected relations will also be deleted."
deleteButtonLabel="Delete"
>
{/* Actor Type */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Actor Type
</label>
<select
value={actorType}
onChange={(e) => setActorType(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{nodeTypes.map((nodeType) => (
<option key={nodeType.id} value={nodeType.id}>
{nodeType.label}
</option>
))}
</select>
{selectedNodeTypeConfig && (
<div
className="mt-2 h-8 rounded border-2 flex items-center justify-center text-xs font-medium text-white"
style={{
backgroundColor: selectedNodeTypeConfig.color,
borderColor: selectedNodeTypeConfig.color,
}}
>
Color Preview
</div>
)}
</div>
{/* Actor Label */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Label *
</label>
<input
ref={labelInputRef}
type="text"
value={actorLabel}
onChange={(e) => setActorLabel(e.target.value)}
placeholder="Enter actor name"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Description (optional)
</label>
<textarea
value={actorDescription}
onChange={(e) => setActorDescription(e.target.value)}
placeholder="Add a description"
rows={3}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
{/* Node Info */}
{selectedNode && (
<div className="pt-3 border-t border-gray-200">
<p className="text-xs text-gray-500">
<span className="font-medium">Node ID:</span> {selectedNode.id}
</p>
<p className="text-xs text-gray-500 mt-1">
<span className="font-medium">Position:</span> ({Math.round(selectedNode.position.x)}, {Math.round(selectedNode.position.y)})
</p>
</div>
)}
</PropertyPanel>
);
};
export default NodePropertiesPanel;

View file

@ -0,0 +1,361 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import DocumentManager from '../Workspace/DocumentManager';
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
import { useConfirm } from '../../hooks/useConfirm';
/**
* MenuBar Component
*
* Top-level menu bar with dropdown menus for:
* - File: Document and workspace operations
* - Edit: Configuration and settings
* - View: Display and navigation options
* - Help: Documentation and keyboard shortcuts
*/
interface MenuBarProps {
onOpenHelp?: () => void;
onFitView?: () => void;
onSelectAll?: () => void;
}
const MenuBar: React.FC<MenuBarProps> = ({ onOpenHelp, onFitView, onSelectAll }) => {
const [activeMenu, setActiveMenu] = useState<string | null>(null);
const [showDocumentManager, setShowDocumentManager] = useState(false);
const [showNodeConfig, setShowNodeConfig] = useState(false);
const [showEdgeConfig, setShowEdgeConfig] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const { confirm, ConfirmDialogComponent } = useConfirm();
const {
createDocument,
createDocumentFromTemplate,
activeDocumentId,
exportDocument,
importDocumentFromFile,
switchToDocument,
exportAllDocumentsAsZip,
exportWorkspace,
importWorkspace,
} = useWorkspaceStore();
const { clearGraph } = useGraphWithHistory();
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setActiveMenu(null);
}
};
if (activeMenu) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeMenu]);
const toggleMenu = useCallback((menuName: string) => {
setActiveMenu((current) => (current === menuName ? null : menuName));
}, []);
const closeMenu = useCallback(() => {
setActiveMenu(null);
}, []);
const handleNewDocument = useCallback(() => {
createDocument();
closeMenu();
}, [createDocument, closeMenu]);
const handleNewDocumentFromTemplate = useCallback(() => {
if (!activeDocumentId) {
alert('Please open a document first to use it as a template');
closeMenu();
return;
}
const newDocId = createDocumentFromTemplate(activeDocumentId);
if (newDocId) {
switchToDocument(newDocId);
}
closeMenu();
}, [createDocumentFromTemplate, activeDocumentId, switchToDocument, closeMenu]);
const handleOpenDocumentManager = useCallback(() => {
setShowDocumentManager(true);
closeMenu();
}, [closeMenu]);
const handleImport = useCallback(async () => {
const newDocId = await importDocumentFromFile();
if (newDocId) {
switchToDocument(newDocId);
}
closeMenu();
}, [importDocumentFromFile, switchToDocument, closeMenu]);
const handleExport = useCallback(() => {
if (activeDocumentId) {
exportDocument(activeDocumentId);
}
closeMenu();
}, [activeDocumentId, exportDocument, closeMenu]);
const handleExportAll = useCallback(() => {
exportAllDocumentsAsZip();
closeMenu();
}, [exportAllDocumentsAsZip, closeMenu]);
const handleExportWorkspace = useCallback(() => {
exportWorkspace();
closeMenu();
}, [exportWorkspace, closeMenu]);
const handleImportWorkspace = useCallback(() => {
importWorkspace();
closeMenu();
}, [importWorkspace, closeMenu]);
const handleConfigureActors = useCallback(() => {
setShowNodeConfig(true);
closeMenu();
}, [closeMenu]);
const handleConfigureRelations = useCallback(() => {
setShowEdgeConfig(true);
closeMenu();
}, [closeMenu]);
const handleClearGraph = useCallback(async () => {
const confirmed = await confirm({
title: 'Clear Current Graph',
message: 'Are you sure you want to clear the current graph? This will remove all actors and relations from this document.',
confirmLabel: 'Clear Graph',
severity: 'danger',
});
if (confirmed) {
clearGraph();
}
closeMenu();
}, [clearGraph, closeMenu, confirm]);
return (
<>
<div ref={menuRef} className="bg-white border-b border-gray-200 shadow-sm">
<div className="flex items-center px-4 py-1">
{/* File Menu */}
<div className="relative">
<button
onClick={() => toggleMenu('file')}
className={`flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeMenu === 'file'
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
File
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</button>
{activeMenu === 'file' && (
<div className="absolute top-full left-0 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-50">
<button
onClick={handleNewDocument}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>New Document</span>
<span className="text-xs text-gray-400">Ctrl+N</span>
</button>
<button
onClick={handleNewDocumentFromTemplate}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
disabled={!activeDocumentId}
>
New from Current Template
</button>
<button
onClick={handleOpenDocumentManager}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Document Manager</span>
<span className="text-xs text-gray-400">Ctrl+O</span>
</button>
<hr className="my-1 border-gray-200" />
<button
onClick={handleImport}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Import Document
</button>
<button
onClick={handleExport}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Export Document</span>
<span className="text-xs text-gray-400">Ctrl+S</span>
</button>
<button
onClick={handleExportAll}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Export All as ZIP
</button>
<hr className="my-1 border-gray-200" />
<button
onClick={handleExportWorkspace}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Export Workspace
</button>
<button
onClick={handleImportWorkspace}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Import Workspace
</button>
</div>
)}
</div>
{/* Edit Menu */}
<div className="relative">
<button
onClick={() => toggleMenu('edit')}
className={`flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeMenu === 'edit'
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Edit
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</button>
{activeMenu === 'edit' && (
<div className="absolute top-full left-0 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-50">
<button
onClick={handleConfigureActors}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Configure Actor Types
</button>
<button
onClick={handleConfigureRelations}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
Configure Relation Types
</button>
<hr className="my-1 border-gray-200" />
<button
onClick={handleClearGraph}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
Clear Current Graph
</button>
</div>
)}
</div>
{/* View Menu */}
<div className="relative">
<button
onClick={() => toggleMenu('view')}
className={`flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeMenu === 'view'
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
View
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</button>
{activeMenu === 'view' && (
<div className="absolute top-full left-0 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-50">
<button
onClick={() => {
onFitView?.();
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Fit View to Content</span>
<span className="text-xs text-gray-400">F</span>
</button>
<button
onClick={() => {
onSelectAll?.();
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Select All</span>
<span className="text-xs text-gray-400">Ctrl+A</span>
</button>
</div>
)}
</div>
{/* Help Menu */}
<div className="relative">
<button
onClick={() => toggleMenu('help')}
className={`flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded transition-colors ${
activeMenu === 'help'
? 'bg-blue-100 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
Help
<ExpandMoreIcon sx={{ fontSize: 16 }} />
</button>
{activeMenu === 'help' && (
<div className="absolute top-full left-0 mt-1 w-56 bg-white border border-gray-200 rounded-md shadow-lg py-1 z-50">
<button
onClick={() => {
onOpenHelp?.();
closeMenu();
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 flex items-center justify-between"
>
<span>Keyboard Shortcuts</span>
<span className="text-xs text-gray-400">?</span>
</button>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
<DocumentManager
isOpen={showDocumentManager}
onClose={() => setShowDocumentManager(false)}
/>
<NodeTypeConfigModal
isOpen={showNodeConfig}
onClose={() => setShowNodeConfig(false)}
/>
<EdgeTypeConfigModal
isOpen={showEdgeConfig}
onClose={() => setShowEdgeConfig(false)}
/>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</>
);
};
export default MenuBar;

View file

@ -0,0 +1,160 @@
import { memo } from 'react';
import { Handle, Position, NodeProps, useStore } from 'reactflow';
import { useGraphStore } from '../../stores/graphStore';
import { getContrastColor, adjustColorBrightness } from '../../utils/colorUtils';
import { getIconComponent } from '../../utils/iconUtils';
import type { ActorData } from '../../types';
/**
* CustomNode - Represents an actor in the constellation graph
*
* Features:
* - Visual representation with type-based coloring
* - Connection handles (top, right, bottom, left)
* - Label display
* - Type badge
*
* Usage: Automatically rendered by React Flow for nodes with type='custom'
*/
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
const nodeTypes = useGraphStore((state) => state.nodeTypes);
// Check if any connection is being made (to show handles)
const connectionNodeId = useStore((state) => state.connectionNodeId);
const isConnecting = !!connectionNodeId;
// Find the node type configuration
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === data.type);
const nodeColor = nodeTypeConfig?.color || '#6b7280';
const nodeLabel = nodeTypeConfig?.label || 'Unknown';
const IconComponent = getIconComponent(nodeTypeConfig?.icon);
// Determine text color based on background
const textColor = getContrastColor(nodeColor);
const borderColor = selected ? adjustColorBrightness(nodeColor, -20) : nodeColor;
// Show handles when selected or when connecting
const showHandles = selected || isConnecting;
return (
<div
className={`
px-4 py-3 rounded-lg shadow-md min-w-[120px]
transition-all duration-200
${selected ? 'shadow-xl' : 'shadow-md'}
`}
style={{
backgroundColor: nodeColor,
borderWidth: '3px', // Keep consistent border width
borderStyle: 'solid',
borderColor: borderColor,
color: textColor,
boxShadow: selected
? `0 0 0 3px ${nodeColor}40` // Add outer glow when selected (40 = ~25% opacity)
: undefined,
}}
>
{/* Connection handles - shown only when selected or connecting */}
<Handle
type="source"
position={Position.Top}
id="top"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Right}
id="right"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Bottom}
id="bottom"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
<Handle
type="source"
position={Position.Left}
id="left"
isConnectable={true}
isConnectableStart={true}
isConnectableEnd={true}
className="w-2 h-2 transition-opacity"
style={{
background: adjustColorBrightness(nodeColor, -30),
opacity: showHandles ? 1 : 0,
border: `1px solid ${textColor}`,
}}
/>
{/* Node content */}
<div className="space-y-1">
{/* Icon (if available) */}
{IconComponent && (
<div className="flex justify-center mb-1" style={{ color: textColor, fontSize: '2rem' }}>
<IconComponent />
</div>
)}
{/* Main label */}
<div
className="text-base font-bold text-center break-words leading-tight"
style={{ color: textColor }}
>
{data.label}
</div>
{/* Type as subtle subtitle */}
<div
className="text-xs text-center opacity-70 font-medium leading-tight"
style={{ color: textColor }}
>
{nodeLabel}
</div>
{/* Description (if available) */}
{data.description && (
<div
className="text-xs text-center opacity-60 pt-2 mt-1 border-t"
style={{
color: textColor,
borderColor: `${textColor}40`, // 40 is hex for ~25% opacity
}}
>
{data.description}
</div>
)}
</div>
</div>
);
};
export default memo(CustomNode);

View file

@ -0,0 +1,147 @@
import { useCallback, useEffect } from 'react';
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
import { useEditorStore } from '../../stores/editorStore';
import { useDocumentHistory } from '../../hooks/useDocumentHistory';
import { createNode } from '../../utils/nodeUtils';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import { Tooltip, IconButton } from '@mui/material';
/**
* Toolbar - Graph editing tools
*
* Features:
* - Undo/Redo buttons with keyboard shortcuts
* - Add Actor buttons (by type)
* - Relation type selector
* - Visual node type palette
*
* Usage: Placed below tabs, provides quick access to graph editing tools
*/
const Toolbar = () => {
const { nodeTypes, edgeTypes, addNode } = useGraphWithHistory();
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
const { undo, redo, canUndo, canRedo, undoDescription, redoDescription } = useDocumentHistory();
// Set default relation type on mount or when edge types change
useEffect(() => {
if (!selectedRelationType && edgeTypes.length > 0) {
setSelectedRelationType(edgeTypes[0].id);
}
}, [edgeTypes, selectedRelationType, setSelectedRelationType]);
const handleAddNode = useCallback(
(nodeTypeId: string) => {
// Create node at center of viewport (approximate)
const position = {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
};
const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId);
const newNode = createNode(nodeTypeId, position, nodeTypeConfig);
addNode(newNode);
},
[addNode, nodeTypes]
);
const selectedEdgeTypeConfig = edgeTypes.find(et => et.id === selectedRelationType);
return (
<div className="bg-white border-b border-gray-200 shadow-sm">
<div className="px-4 py-3">
<div className="flex items-center space-x-6">
{/* Undo/Redo */}
<div className="flex items-center space-x-1">
<Tooltip
title={undoDescription ? `Undo: ${undoDescription} (Ctrl+Z)` : 'Undo (Ctrl+Z)'}
arrow
>
<span>
<IconButton
onClick={undo}
disabled={!canUndo}
size="small"
sx={{
'&:disabled': {
opacity: 0.4,
}
}}
>
<UndoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip
title={redoDescription ? `Redo: ${redoDescription} (Ctrl+Y)` : 'Redo (Ctrl+Y)'}
arrow
>
<span>
<IconButton
onClick={redo}
disabled={!canRedo}
size="small"
sx={{
'&:disabled': {
opacity: 0.4,
}
}}
>
<RedoIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</div>
{/* Add Actor */}
<div className="flex items-center space-x-2 border-l border-gray-300 pl-6">
<h2 className="text-sm font-semibold text-gray-700">Add Actor:</h2>
<div className="flex space-x-2">
{nodeTypes.map((nodeType) => (
<button
key={nodeType.id}
onClick={() => handleAddNode(nodeType.id)}
className="px-3 py-2 text-sm font-medium text-white rounded-md hover:opacity-90 transition-opacity focus:outline-none focus:ring-2 focus:ring-offset-2"
style={{ backgroundColor: nodeType.color }}
title={nodeType.description}
>
{nodeType.label}
</button>
))}
</div>
</div>
{/* Relation Type Selector */}
<div className="flex items-center space-x-2 border-l border-gray-300 pl-6">
<h2 className="text-sm font-semibold text-gray-700">Relation Type:</h2>
<select
value={selectedRelationType || ''}
onChange={(e) => setSelectedRelationType(e.target.value)}
className="px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
style={{
borderLeftWidth: '4px',
borderLeftColor: selectedEdgeTypeConfig?.color || '#6b7280'
}}
>
{edgeTypes.map((edgeType) => (
<option key={edgeType.id} value={edgeType.id}>
{edgeType.label}
</option>
))}
</select>
</div>
</div>
{/* Instructions */}
<div className="mt-3 text-xs text-gray-500">
<p>
Click a button to add an actor. Drag actors to position them. Select a relation type above, then click
and drag from a handle to create a relation between actors.
</p>
</div>
</div>
</div>
);
};
export default Toolbar;

View file

@ -0,0 +1,165 @@
import { useState } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import FileCopyIcon from '@mui/icons-material/FileCopy';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import type { DocumentMetadata } from '../../stores/workspace/types';
/**
* DocumentCard Component
*
* Card displaying a document in the document manager grid
* Features:
* - Document title and metadata
* - Last modified timestamp
* - Click to open
* - Actions menu (duplicate, export, delete)
*/
interface DocumentCardProps {
metadata: DocumentMetadata;
isOpen?: boolean;
onClick: () => void;
onDuplicate: () => void;
onExport: () => void;
onDelete: () => void;
}
const DocumentCard = ({
metadata,
isOpen = false,
onClick,
onDuplicate,
onExport,
onDelete,
}: DocumentCardProps) => {
const [showMenu, setShowMenu] = useState(false);
const handleMenuClick = (e: React.MouseEvent) => {
e.stopPropagation();
setShowMenu(!showMenu);
};
const handleAction = (e: React.MouseEvent, action: () => void) => {
e.stopPropagation();
setShowMenu(false);
action();
};
const formatDate = (isoString: string) => {
const date = new Date(isoString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const minutes = Math.floor(diff / (1000 * 60));
return minutes === 0 ? 'Just now' : `${minutes}m ago`;
}
return `${hours}h ago`;
}
if (days === 1) return 'Yesterday';
if (days < 7) return `${days} days ago`;
return date.toLocaleDateString();
};
return (
<div
className={`relative bg-white border rounded-lg p-4 cursor-pointer hover:shadow-md transition-shadow group ${
isOpen ? 'border-blue-400 bg-blue-50' : 'border-gray-200'
}`}
onClick={onClick}
>
{/* Open indicator badge */}
{isOpen && (
<div className="absolute top-2 left-2 px-2 py-0.5 bg-blue-500 text-white text-xs rounded-full font-medium">
Open
</div>
)}
{/* Dirty indicator */}
{metadata.isDirty && (
<div
className="absolute top-2 right-2 w-2 h-2 rounded-full bg-orange-500"
title="Unsaved changes"
/>
)}
{/* Actions menu button */}
<button
onClick={handleMenuClick}
className="absolute top-2 right-2 p-1 rounded hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
title="More actions"
>
<MoreVertIcon sx={{ fontSize: 18 }} className="text-gray-500" />
</button>
{/* Actions menu dropdown */}
{showMenu && (
<>
{/* Backdrop to close menu */}
<div
className="fixed inset-0 z-10"
onClick={(e) => {
e.stopPropagation();
setShowMenu(false);
}}
/>
{/* Menu */}
<div className="absolute top-8 right-2 z-20 bg-white border border-gray-200 rounded-md shadow-lg py-1 min-w-[160px]">
<button
onClick={(e) => handleAction(e, onDuplicate)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2"
>
<FileCopyIcon sx={{ fontSize: 16 }} className="text-gray-500" />
Duplicate
</button>
<button
onClick={(e) => handleAction(e, onExport)}
className="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 flex items-center gap-2"
>
<FileDownloadIcon sx={{ fontSize: 16 }} className="text-gray-500" />
Export
</button>
<hr className="my-1 border-gray-200" />
<button
onClick={(e) => handleAction(e, onDelete)}
className="w-full px-4 py-2 text-left text-sm hover:bg-red-50 text-red-600 flex items-center gap-2"
>
<DeleteIcon sx={{ fontSize: 16 }} />
Delete
</button>
</div>
</>
)}
{/* Color indicator */}
{metadata.color && (
<div
className="absolute left-0 top-0 bottom-0 w-1 rounded-l-lg"
style={{ backgroundColor: metadata.color }}
/>
)}
{/* Title */}
<h3 className="font-semibold text-gray-900 mb-2 pr-6 truncate">
{metadata.title}
</h3>
{/* Metadata */}
<div className="text-xs text-gray-500 space-y-1">
<div>Modified: {formatDate(metadata.lastModified)}</div>
{metadata.fileName && (
<div className="truncate" title={metadata.fileName}>
File: {metadata.fileName}
</div>
)}
</div>
</div>
);
};
export default DocumentCard;

View file

@ -0,0 +1,280 @@
import { useCallback, useState, useMemo } from 'react';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import FolderZipIcon from '@mui/icons-material/FolderZip';
import SearchIcon from '@mui/icons-material/Search';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import { useConfirm } from '../../hooks/useConfirm';
import DocumentCard from './DocumentCard';
/**
* DocumentManager Component
*
* Modal/panel showing all documents in the workspace
* Features:
* - Grid view of all documents
* - Create new document
* - Import document from file
* - Duplicate/export/delete documents
*/
interface DocumentManagerProps {
isOpen: boolean;
onClose: () => void;
}
const DocumentManager = ({ isOpen, onClose }: DocumentManagerProps) => {
const {
documentMetadata,
documentOrder,
createDocument,
switchToDocument,
duplicateDocument,
exportDocument,
deleteDocument,
importDocumentFromFile,
exportAllDocumentsAsZip,
exportWorkspace,
importWorkspace,
} = useWorkspaceStore();
const { confirm, ConfirmDialogComponent } = useConfirm();
const [searchQuery, setSearchQuery] = useState('');
// Get all document IDs from metadata (includes both open and closed documents)
const allDocumentIds = useMemo(() => {
return Array.from(documentMetadata.keys());
}, [documentMetadata]);
// Filter documents based on search query
const filteredDocuments = useMemo(() => {
const docsToFilter = searchQuery.trim() ? allDocumentIds : allDocumentIds;
if (!searchQuery.trim()) {
return allDocumentIds;
}
const query = searchQuery.toLowerCase();
return docsToFilter.filter((docId) => {
const meta = documentMetadata.get(docId);
if (!meta) return false;
return (
meta.title.toLowerCase().includes(query) ||
meta.fileName?.toLowerCase().includes(query)
);
});
}, [allDocumentIds, documentMetadata, searchQuery]);
const handleNewDocument = useCallback(() => {
const newDocId = createDocument();
switchToDocument(newDocId);
onClose();
}, [createDocument, switchToDocument, onClose]);
const handleImportDocument = useCallback(async () => {
const newDocId = await importDocumentFromFile();
if (newDocId) {
switchToDocument(newDocId);
onClose();
}
}, [importDocumentFromFile, switchToDocument, onClose]);
const handleOpenDocument = useCallback(
(documentId: string) => {
switchToDocument(documentId);
onClose();
},
[switchToDocument, onClose]
);
const handleDuplicateDocument = useCallback(
(documentId: string) => {
const newDocId = duplicateDocument(documentId);
switchToDocument(newDocId);
onClose();
},
[duplicateDocument, switchToDocument, onClose]
);
const handleExportDocument = useCallback(
(documentId: string) => {
exportDocument(documentId);
},
[exportDocument]
);
const handleDeleteDocument = useCallback(
async (documentId: string) => {
const meta = documentMetadata.get(documentId);
if (!meta) return;
const confirmTitle = 'Delete Document';
const confirmMessage = meta.isDirty
? `"${meta.title}" has unsaved changes. Delete anyway?`
: `Are you sure you want to delete "${meta.title}"?`;
const confirmed = await confirm({
title: confirmTitle,
message: confirmMessage,
confirmLabel: 'Delete',
severity: 'danger',
});
if (confirmed) {
deleteDocument(documentId);
}
},
[documentMetadata, deleteDocument, confirm]
);
if (!isOpen) return null;
return (
<>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40"
onClick={onClose}
/>
{/* Modal */}
<div className="fixed inset-4 md:inset-8 lg:inset-16 bg-white rounded-lg shadow-2xl z-50 flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Document Manager</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-gray-100 transition-colors"
title="Close"
>
<CloseIcon className="text-gray-500" />
</button>
</div>
{/* Actions bar */}
<div className="flex flex-col gap-3 px-6 py-4 border-b border-gray-200 bg-gray-50">
<div className="flex gap-3 flex-wrap">
<button
onClick={handleNewDocument}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<AddIcon sx={{ fontSize: 20 }} />
New Document
</button>
<button
onClick={handleImportDocument}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
>
<FileUploadIcon sx={{ fontSize: 20 }} />
Import Document
</button>
<div className="flex-1" />
<button
onClick={exportAllDocumentsAsZip}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
title="Export all documents as ZIP"
>
<FolderZipIcon sx={{ fontSize: 20 }} />
Export All
</button>
<button
onClick={exportWorkspace}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
title="Export workspace with all documents and settings"
>
<FileDownloadIcon sx={{ fontSize: 20 }} />
Export Workspace
</button>
<button
onClick={importWorkspace}
className="flex items-center gap-2 px-4 py-2 bg-white border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 transition-colors"
title="Import workspace from ZIP"
>
<FileUploadIcon sx={{ fontSize: 20 }} />
Import Workspace
</button>
</div>
{/* Search bar */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" sx={{ fontSize: 20 }} />
<input
type="text"
placeholder="Search documents..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Document grid */}
<div className="flex-1 overflow-y-auto p-6">
{allDocumentIds.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<p className="text-lg mb-4">No documents yet</p>
<button
onClick={handleNewDocument}
className="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
Create your first document
</button>
</div>
) : filteredDocuments.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<SearchIcon sx={{ fontSize: 48 }} className="mb-4" />
<p className="text-lg">No documents match "{searchQuery}"</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{filteredDocuments.map((docId) => {
const meta = documentMetadata.get(docId);
if (!meta) return null;
const isOpen = documentOrder.includes(docId);
return (
<DocumentCard
key={docId}
metadata={meta}
isOpen={isOpen}
onClick={() => handleOpenDocument(docId)}
onDuplicate={() => handleDuplicateDocument(docId)}
onExport={() => handleExportDocument(docId)}
onDelete={() => handleDeleteDocument(docId)}
/>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50 text-sm text-gray-600">
{searchQuery ? (
<>
Showing {filteredDocuments.length} of {allDocumentIds.length} document{allDocumentIds.length !== 1 ? 's' : ''}
</>
) : (
<>
{allDocumentIds.length} document{allDocumentIds.length !== 1 ? 's' : ''} in workspace
{documentOrder.length > 0 && documentOrder.length < allDocumentIds.length && (
<> {documentOrder.length} open</>
)}
</>
)}
</div>
</div>
{/* Confirmation Dialog */}
{ConfirmDialogComponent}
</>
);
};
export default DocumentManager;

View file

@ -0,0 +1,227 @@
import { useCallback, useState } from 'react';
import { useWorkspaceStore } from '../../stores/workspaceStore';
import Tab from './Tab';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import FileCopyIcon from '@mui/icons-material/FileCopy';
import FileDownloadIcon from '@mui/icons-material/FileDownload';
import CloseIcon from '@mui/icons-material/Close';
import DeleteIcon from '@mui/icons-material/Delete';
import ContextMenu from '../Editor/ContextMenu';
/**
* DocumentTabs Component
*
* Tab bar for managing multiple documents:
* - Displays all open documents as tabs
* - Highlights active document
* - Shows dirty state indicators
* - New tab button
* - Tab switching, closing, renaming
* - Drag-to-reorder tabs
*/
const DocumentTabs = () => {
const {
documentOrder,
activeDocumentId,
documentMetadata,
switchToDocument,
closeDocument,
renameDocument,
createDocument,
reorderDocuments,
duplicateDocument,
exportDocument,
deleteDocument,
} = useWorkspaceStore();
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
documentId: string;
} | null>(null);
const [renameDocumentId, setRenameDocumentId] = useState<string | null>(null);
const handleTabClick = useCallback(
(documentId: string) => {
if (documentId !== activeDocumentId) {
switchToDocument(documentId);
}
},
[activeDocumentId, switchToDocument]
);
const handleTabClose = useCallback(
(documentId: string) => {
closeDocument(documentId);
},
[closeDocument]
);
const handleTabRename = useCallback(
(documentId: string, newTitle: string) => {
renameDocument(documentId, newTitle);
},
[renameDocument]
);
const handleNewDocument = useCallback(() => {
createDocument();
}, [createDocument]);
const handleDragStart = useCallback((index: number) => {
setDraggedIndex(index);
}, []);
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex === null || draggedIndex === index) return;
const newOrder = [...documentOrder];
const draggedItem = newOrder[draggedIndex];
newOrder.splice(draggedIndex, 1);
newOrder.splice(index, 0, draggedItem);
reorderDocuments(newOrder);
setDraggedIndex(index);
}, [draggedIndex, documentOrder, reorderDocuments]);
const handleDragEnd = useCallback(() => {
setDraggedIndex(null);
}, []);
const handleTabContextMenu = useCallback((e: React.MouseEvent, documentId: string) => {
e.preventDefault();
setContextMenu({
x: e.clientX,
y: e.clientY,
documentId,
});
}, []);
const handleRenameFromMenu = useCallback(() => {
if (!contextMenu) return;
setRenameDocumentId(contextMenu.documentId);
setContextMenu(null);
}, [contextMenu]);
const handleDuplicateFromMenu = useCallback(() => {
if (!contextMenu) return;
const newDocId = duplicateDocument(contextMenu.documentId);
switchToDocument(newDocId);
setContextMenu(null);
}, [contextMenu, duplicateDocument, switchToDocument]);
const handleExportFromMenu = useCallback(() => {
if (!contextMenu) return;
exportDocument(contextMenu.documentId);
setContextMenu(null);
}, [contextMenu, exportDocument]);
const handleCloseFromMenu = useCallback(() => {
if (!contextMenu) return;
closeDocument(contextMenu.documentId);
setContextMenu(null);
}, [contextMenu, closeDocument]);
const handleDeleteFromMenu = useCallback(() => {
if (!contextMenu) return;
deleteDocument(contextMenu.documentId);
setContextMenu(null);
}, [contextMenu, deleteDocument]);
return (
<div className="flex items-center bg-gray-100 border-b border-gray-300 overflow-x-auto">
{/* Document tabs */}
<div className="flex flex-1 overflow-x-auto">
{documentOrder.map((docId, index) => {
const metadata = documentMetadata.get(docId);
if (!metadata) return null;
return (
<div
key={docId}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
className={draggedIndex === index ? 'opacity-50' : ''}
>
<Tab
id={docId}
title={metadata.title}
isActive={docId === activeDocumentId}
isDirty={metadata.isDirty}
color={metadata.color}
onClick={() => handleTabClick(docId)}
onClose={() => handleTabClose(docId)}
onRename={(newTitle) => handleTabRename(docId, newTitle)}
onContextMenu={(e) => handleTabContextMenu(e, docId)}
triggerRename={renameDocumentId === docId}
onRenameDone={() => setRenameDocumentId(null)}
/>
</div>
);
})}
</div>
{/* New tab button */}
<button
onClick={handleNewDocument}
className="flex items-center gap-1 px-3 py-2 text-sm text-gray-600 hover:bg-gray-200 transition-colors border-l border-gray-300"
title="New document"
>
<AddIcon sx={{ fontSize: 18 }} />
<span className="hidden sm:inline">New</span>
</button>
{/* Tab Context Menu */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
sections={[
{
actions: [
{
label: 'Rename',
icon: <EditIcon fontSize="small" />,
onClick: handleRenameFromMenu,
},
{
label: 'Duplicate',
icon: <FileCopyIcon fontSize="small" />,
onClick: handleDuplicateFromMenu,
},
{
label: 'Export',
icon: <FileDownloadIcon fontSize="small" />,
onClick: handleExportFromMenu,
},
],
},
{
actions: [
{
label: 'Close',
icon: <CloseIcon fontSize="small" />,
onClick: handleCloseFromMenu,
},
{
label: 'Delete',
icon: <DeleteIcon fontSize="small" />,
onClick: handleDeleteFromMenu,
},
],
},
]}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
};
export default DocumentTabs;

View file

@ -0,0 +1,162 @@
import { useState, useCallback, useEffect } from 'react';
import CloseIcon from '@mui/icons-material/Close';
/**
* Tab Component
*
* Individual tab for a document with:
* - Active/inactive states
* - Dirty indicator (unsaved changes)
* - Close button
* - Double-click to rename
* - Context menu support
* - Programmatic rename trigger
*/
interface TabProps {
id: string;
title: string;
isActive: boolean;
isDirty: boolean;
color?: string;
onClick: () => void;
onClose: () => void;
onRename?: (newTitle: string) => void;
onContextMenu?: (e: React.MouseEvent) => void;
triggerRename?: boolean; // NEW: Trigger rename mode from outside
onRenameDone?: () => void; // NEW: Notify when rename is complete
}
const Tab = ({
title,
isActive,
isDirty,
color,
onClick,
onClose,
onRename,
onContextMenu,
triggerRename,
onRenameDone,
}: TabProps) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
// Trigger edit mode when requested from outside
useEffect(() => {
if (triggerRename && onRename) {
setEditValue(title);
setIsEditing(true);
onRenameDone?.(); // Clear the trigger
}
}, [triggerRename, onRename, title, onRenameDone]);
const handleDoubleClick = useCallback(() => {
if (onRename) {
setEditValue(title);
setIsEditing(true);
}
}, [title, onRename]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (editValue.trim()) {
onRename?.(editValue.trim());
}
setIsEditing(false);
} else if (e.key === 'Escape') {
setEditValue(title);
setIsEditing(false);
}
},
[editValue, title, onRename]
);
const handleBlur = useCallback(() => {
if (editValue.trim()) {
onRename?.(editValue.trim());
}
setIsEditing(false);
}, [editValue, onRename]);
const handleCloseClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onClose();
},
[onClose]
);
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
onContextMenu?.(e);
},
[onContextMenu]
);
return (
<div
className={`
group relative flex items-center gap-2 px-4 py-2 min-w-[120px] max-w-[200px]
border-r border-gray-200 cursor-pointer select-none
transition-colors duration-150
${isActive
? 'bg-white border-b-2 border-b-blue-500'
: 'bg-gray-50 hover:bg-gray-100'
}
`}
onClick={onClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
style={color ? { borderLeftWidth: '3px', borderLeftColor: color } : undefined}
>
{/* Dirty indicator */}
{isDirty && (
<div
className="w-2 h-2 rounded-full bg-orange-500"
title="Unsaved changes"
/>
)}
{/* Title */}
{isEditing ? (
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
autoFocus
className="flex-1 min-w-0 px-1 py-0 text-sm bg-white border border-blue-500 rounded outline-none"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className={`
flex-1 min-w-0 text-sm truncate
${isActive ? 'font-semibold text-gray-900' : 'text-gray-600'}
`}
title={title}
>
{title}
</span>
)}
{/* Close button */}
<button
onClick={handleCloseClick}
className={`
p-0.5 rounded hover:bg-gray-200 transition-opacity
${isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}
`}
title="Close tab"
>
<CloseIcon sx={{ fontSize: 16 }} className="text-gray-500" />
</button>
</div>
);
};
export default Tab;

View file

@ -0,0 +1,62 @@
/**
* UnsavedChangesDialog Component
*
* Modal dialog shown when closing a document with unsaved changes
* Currently using browser confirm() but this provides a foundation
* for a custom dialog in the future
*/
interface UnsavedChangesDialogProps {
documentTitle: string;
onSave: () => void;
onDiscard: () => void;
onCancel: () => void;
}
const UnsavedChangesDialog = ({
documentTitle,
onSave,
onDiscard,
onCancel,
}: UnsavedChangesDialogProps) => {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
{/* Header */}
<h2 className="text-xl font-bold text-gray-900 mb-2">
Unsaved Changes
</h2>
{/* Message */}
<p className="text-gray-600 mb-6">
The document <span className="font-semibold">&quot;{documentTitle}&quot;</span> has unsaved changes.
Do you want to save before closing?
</p>
{/* Actions */}
<div className="flex gap-3 justify-end">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={onDiscard}
className="px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-md hover:bg-red-100 transition-colors"
>
Discard Changes
</button>
<button
onClick={onSave}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 transition-colors"
>
Save & Close
</button>
</div>
</div>
</div>
);
};
export default UnsavedChangesDialog;

View file

@ -0,0 +1,54 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useKeyboardShortcutManager } from '../hooks/useKeyboardShortcutManager';
/**
* Keyboard Shortcut Context
*
* Provides centralized keyboard shortcut management throughout the application.
* Components can register shortcuts and the system handles conflicts and priorities.
*/
interface KeyboardShortcutContextValue {
shortcuts: ReturnType<typeof useKeyboardShortcutManager>;
}
const KeyboardShortcutContext = createContext<KeyboardShortcutContextValue | null>(null);
interface KeyboardShortcutProviderProps {
children: ReactNode;
}
export const KeyboardShortcutProvider: React.FC<KeyboardShortcutProviderProps> = ({
children,
}) => {
const shortcuts = useKeyboardShortcutManager();
return (
<KeyboardShortcutContext.Provider value={{ shortcuts }}>
{children}
</KeyboardShortcutContext.Provider>
);
};
/**
* Hook to access keyboard shortcut manager
*
* Usage:
* ```tsx
* const { shortcuts } = useKeyboardShortcuts();
*
* useEffect(() => {
* shortcuts.register({...});
* return () => shortcuts.unregister('id');
* }, []);
* ```
*/
export function useKeyboardShortcuts() {
const context = useContext(KeyboardShortcutContext);
if (!context) {
throw new Error(
'useKeyboardShortcuts must be used within KeyboardShortcutProvider'
);
}
return context;
}

94
src/hooks/useConfirm.tsx Normal file
View file

@ -0,0 +1,94 @@
import { useState, useCallback } from 'react';
import ConfirmDialog, { ConfirmDialogSeverity } from '../components/Common/ConfirmDialog';
/**
* useConfirm Hook
*
* A custom hook that provides a confirm dialog function and the dialog component.
* This replaces window.confirm with a styled, promise-based confirmation dialog.
*
* Usage:
* ```tsx
* const { confirm, ConfirmDialogComponent } = useConfirm();
*
* const handleDelete = async () => {
* const confirmed = await confirm({
* title: 'Delete Item',
* message: 'Are you sure you want to delete this item?',
* severity: 'danger'
* });
* if (confirmed) {
* // Perform deletion
* }
* };
*
* return (
* <>
* <button onClick={handleDelete}>Delete</button>
* {ConfirmDialogComponent}
* </>
* );
* ```
*/
interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
severity?: ConfirmDialogSeverity;
}
interface ConfirmState extends ConfirmOptions {
isOpen: boolean;
resolve?: (value: boolean) => void;
}
export const useConfirm = () => {
const [state, setState] = useState<ConfirmState>({
isOpen: false,
title: '',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
severity: 'warning',
});
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
return new Promise((resolve) => {
setState({
isOpen: true,
...options,
resolve,
});
});
}, []);
const handleConfirm = useCallback(() => {
state.resolve?.(true);
setState((prev) => ({ ...prev, isOpen: false }));
}, [state.resolve]);
const handleCancel = useCallback(() => {
state.resolve?.(false);
setState((prev) => ({ ...prev, isOpen: false }));
}, [state.resolve]);
const ConfirmDialogComponent = (
<ConfirmDialog
isOpen={state.isOpen}
title={state.title}
message={state.message}
confirmLabel={state.confirmLabel}
cancelLabel={state.cancelLabel}
severity={state.severity}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
);
return {
confirm,
ConfirmDialogComponent,
};
};

View file

@ -0,0 +1,186 @@
import { useCallback, useEffect } from 'react';
import { useWorkspaceStore } from '../stores/workspaceStore';
import { useHistoryStore } from '../stores/historyStore';
import { useGraphStore } from '../stores/graphStore';
import type { ConstellationDocument } from '../stores/persistence/types';
import { createDocument } from '../stores/persistence/saver';
/**
* useDocumentHistory Hook
*
* Provides undo/redo functionality for the active document.
* Each document has its own independent history stack (max 50 actions).
*
* IMPORTANT: History is per-document. Switching documents maintains separate undo/redo stacks.
*
* Usage:
* const { undo, redo, canUndo, canRedo, pushToHistory } = useDocumentHistory();
*/
export function useDocumentHistory() {
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
const getActiveDocument = useWorkspaceStore((state) => state.getActiveDocument);
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
const setNodes = useGraphStore((state) => state.setNodes);
const setEdges = useGraphStore((state) => state.setEdges);
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const historyStore = useHistoryStore();
// Initialize history for active document
useEffect(() => {
if (!activeDocumentId) return;
const history = historyStore.histories.get(activeDocumentId);
if (!history) {
const currentDoc = getActiveDocument();
if (currentDoc) {
historyStore.initializeHistory(activeDocumentId, currentDoc);
}
}
}, [activeDocumentId, historyStore, getActiveDocument]);
/**
* Push current graph state to history
*/
const pushToHistory = useCallback(
(description: string) => {
if (!activeDocumentId) {
console.warn('No active document to record action');
return;
}
const currentDoc = getActiveDocument();
if (!currentDoc) {
console.warn('Active document not loaded');
return;
}
// Read current state directly from store (not from React hooks which might be stale)
const currentState = useGraphStore.getState();
// Create a snapshot of the current state
const snapshot: ConstellationDocument = createDocument(
currentState.nodes as never[],
currentState.edges as never[],
currentState.nodeTypes,
currentState.edgeTypes
);
// Copy metadata from current document
snapshot.metadata = {
...currentDoc.metadata,
updatedAt: new Date().toISOString(),
};
// Push to history
historyStore.pushAction(activeDocumentId, {
description,
timestamp: Date.now(),
documentState: snapshot,
});
},
[activeDocumentId, historyStore, getActiveDocument]
);
/**
* Undo the last action for the active document
*/
const undo = useCallback(() => {
if (!activeDocumentId) {
console.warn('No active document to undo');
return;
}
const restoredState = historyStore.undo(activeDocumentId);
if (restoredState) {
// Update graph store with restored state
setNodes(restoredState.graph.nodes as never[]);
setEdges(restoredState.graph.edges as never[]);
setNodeTypes(restoredState.graph.nodeTypes);
setEdgeTypes(restoredState.graph.edgeTypes);
// Update workspace document
const { documents, saveDocument } = useWorkspaceStore.getState();
const newDocuments = new Map(documents);
newDocuments.set(activeDocumentId, restoredState);
useWorkspaceStore.setState({ documents: newDocuments });
// Mark document as dirty and trigger auto-save
markDocumentDirty(activeDocumentId);
// Auto-save after a short delay
setTimeout(() => {
saveDocument(activeDocumentId);
}, 1000);
}
}, [activeDocumentId, historyStore, setNodes, setEdges, setNodeTypes, setEdgeTypes, markDocumentDirty]);
/**
* Redo the last undone action for the active document
*/
const redo = useCallback(() => {
if (!activeDocumentId) {
console.warn('No active document to redo');
return;
}
const restoredState = historyStore.redo(activeDocumentId);
if (restoredState) {
// Update graph store with restored state
setNodes(restoredState.graph.nodes as never[]);
setEdges(restoredState.graph.edges as never[]);
setNodeTypes(restoredState.graph.nodeTypes);
setEdgeTypes(restoredState.graph.edgeTypes);
// Update workspace document
const { documents, saveDocument } = useWorkspaceStore.getState();
const newDocuments = new Map(documents);
newDocuments.set(activeDocumentId, restoredState);
useWorkspaceStore.setState({ documents: newDocuments });
// Mark document as dirty and trigger auto-save
markDocumentDirty(activeDocumentId);
// Auto-save after a short delay
setTimeout(() => {
saveDocument(activeDocumentId);
}, 1000);
}
}, [activeDocumentId, historyStore, setNodes, setEdges, setNodeTypes, setEdgeTypes, markDocumentDirty]);
/**
* Check if undo is available for the active document
*/
const canUndo = activeDocumentId ? historyStore.canUndo(activeDocumentId) : false;
/**
* Check if redo is available for the active document
*/
const canRedo = activeDocumentId ? historyStore.canRedo(activeDocumentId) : false;
/**
* Get the description of the next undo action
*/
const undoDescription = activeDocumentId
? historyStore.getUndoDescription(activeDocumentId)
: null;
/**
* Get the description of the next redo action
*/
const redoDescription = activeDocumentId
? historyStore.getRedoDescription(activeDocumentId)
: null;
return {
undo,
redo,
canUndo,
canRedo,
undoDescription,
redoDescription,
pushToHistory,
};
}

View file

@ -0,0 +1,205 @@
import { useEffect } from 'react';
import { useKeyboardShortcuts } from '../contexts/KeyboardShortcutContext';
import { useWorkspaceStore } from '../stores/workspaceStore';
import type { KeyboardShortcut } from './useKeyboardShortcutManager';
/**
* useGlobalShortcuts Hook
*
* Registers all global keyboard shortcuts for the application.
* This centralizes shortcut registration and makes them discoverable.
*/
interface UseGlobalShortcutsOptions {
onOpenDocumentManager?: () => void;
onUndo?: () => void;
onRedo?: () => void;
onOpenHelp?: () => void;
onFitView?: () => void;
onSelectAll?: () => void;
}
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
const { shortcuts } = useKeyboardShortcuts();
const {
documentOrder,
activeDocumentId,
switchToDocument,
closeDocument,
createDocument,
saveDocument,
} = useWorkspaceStore();
useEffect(() => {
const shortcutDefinitions: KeyboardShortcut[] = [
// Document Management
{
id: 'new-document',
description: 'New Document',
key: 'n',
ctrl: true,
handler: () => createDocument(),
category: 'Document Management',
},
{
id: 'open-document-manager',
description: 'Document Manager',
key: 'o',
ctrl: true,
handler: () => options.onOpenDocumentManager?.(),
category: 'Document Management',
enabled: !!options.onOpenDocumentManager,
},
{
id: 'save-document',
description: 'Export Document',
key: 's',
ctrl: true,
handler: () => {
if (activeDocumentId) {
saveDocument(activeDocumentId);
}
},
category: 'Document Management',
},
{
id: 'close-document',
description: 'Close Current Document',
key: 'w',
ctrl: true,
handler: () => {
if (activeDocumentId && documentOrder.length > 1) {
closeDocument(activeDocumentId);
}
},
category: 'Document Management',
},
{
id: 'next-document',
description: 'Next Document',
key: 'Tab',
ctrl: true,
handler: () => {
const currentIndex = documentOrder.findIndex(id => id === activeDocumentId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % documentOrder.length;
switchToDocument(documentOrder[nextIndex]);
}
},
category: 'Navigation',
},
{
id: 'previous-document',
description: 'Previous Document',
key: 'Tab',
ctrl: true,
shift: true,
handler: () => {
const currentIndex = documentOrder.findIndex(id => id === activeDocumentId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + documentOrder.length) % documentOrder.length;
switchToDocument(documentOrder[prevIndex]);
}
},
category: 'Navigation',
},
// Graph Editing
{
id: 'undo',
description: 'Undo',
key: 'z',
ctrl: true,
handler: () => options.onUndo?.(),
category: 'Graph Editing',
enabled: !!options.onUndo,
},
{
id: 'redo',
description: 'Redo',
key: 'y',
ctrl: true,
handler: () => options.onRedo?.(),
category: 'Graph Editing',
enabled: !!options.onRedo,
},
{
id: 'redo-alt',
description: 'Redo',
key: 'z',
ctrl: true,
shift: true,
handler: () => options.onRedo?.(),
category: 'Graph Editing',
enabled: !!options.onRedo,
priority: -1, // Lower priority than Ctrl+Y
},
// Selection
{
id: 'select-all',
description: 'Select All',
key: 'a',
ctrl: true,
handler: () => options.onSelectAll?.(),
category: 'Selection',
enabled: !!options.onSelectAll,
},
{
id: 'deselect-all',
description: 'Deselect All',
key: 'Escape',
handler: () => {
// This will be handled by GraphEditor
// Just documenting it here
},
category: 'Selection',
enabled: false, // React Flow handles this internally
},
// View
{
id: 'fit-view',
description: 'Fit View to Content',
key: 'f',
handler: () => options.onFitView?.(),
category: 'View',
enabled: !!options.onFitView,
},
{
id: 'show-help',
description: 'Show Keyboard Shortcuts',
key: '?',
handler: () => options.onOpenHelp?.(),
category: 'Navigation',
enabled: !!options.onOpenHelp,
},
];
// Register all shortcuts
shortcutDefinitions.forEach(shortcut => {
shortcuts.register(shortcut);
});
// Cleanup on unmount
return () => {
shortcutDefinitions.forEach(shortcut => {
shortcuts.unregister(shortcut.id);
});
};
}, [
shortcuts,
documentOrder,
activeDocumentId,
switchToDocument,
closeDocument,
createDocument,
saveDocument,
options.onOpenDocumentManager,
options.onUndo,
options.onRedo,
options.onOpenHelp,
options.onFitView,
options.onSelectAll,
]);
}

View file

@ -0,0 +1,288 @@
import { useCallback, useRef, useEffect } from 'react';
import { useGraphStore } from '../stores/graphStore';
import { useDocumentHistory } from './useDocumentHistory';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } from '../types';
/**
* useGraphWithHistory Hook
*
* USE THIS HOOK FOR ALL GRAPH MUTATIONS IN COMPONENTS
*
* This hook wraps graph store operations with automatic history tracking.
* Every operation that modifies the graph pushes a snapshot to the history stack,
* enabling undo/redo functionality.
*
* IMPORTANT: Always use this hook instead of `useGraphStore()` in components
* that modify graph state.
*
* History-tracked operations:
* - Node operations: addNode, updateNode, deleteNode
* - Edge operations: addEdge, updateEdge, deleteEdge
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
* - Utility: clearGraph
*
* Read-only pass-through operations (no history):
* - setNodes, setEdges (used for bulk updates during undo/redo/document loading)
* - nodes, edges, nodeTypes, edgeTypes (state access)
* - exportToFile, importFromFile, loadGraphState
*
* Usage:
* const { addNode, updateNode, deleteNode, ... } = useGraphWithHistory();
*
* // ✅ CORRECT: Uses history tracking
* addNode(newNode);
*
* // ❌ WRONG: Bypasses history
* const graphStore = useGraphStore();
* graphStore.addNode(newNode);
*/
export function useGraphWithHistory() {
const graphStore = useGraphStore();
const { pushToHistory } = useDocumentHistory();
// Track if we're currently restoring from history to prevent recursive history pushes
const isRestoringRef = useRef(false);
// Debounce timer for grouping rapid changes (like dragging)
const debounceTimerRef = useRef<number | null>(null);
const pendingActionRef = useRef<string | null>(null);
// Helper to push history after a debounce delay
const scheduleHistoryPush = useCallback(
(description: string, delay = 300) => {
if (isRestoringRef.current) return;
// Store the description
pendingActionRef.current = description;
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer
debounceTimerRef.current = window.setTimeout(() => {
if (pendingActionRef.current) {
pushToHistory(pendingActionRef.current);
pendingActionRef.current = null;
}
debounceTimerRef.current = null;
}, delay);
},
[pushToHistory]
);
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
// Wrapped operations
const addNode = useCallback(
(node: Actor) => {
if (isRestoringRef.current) {
graphStore.addNode(node);
return;
}
const nodeType = graphStore.nodeTypes.find((nt) => nt.id === node.data.type);
pushToHistory(`Add ${nodeType?.label || 'Actor'}`); // Synchronous push BEFORE mutation
graphStore.addNode(node);
},
[graphStore, pushToHistory]
);
const updateNode = useCallback(
(id: string, updates: Partial<Actor>) => {
if (isRestoringRef.current) {
graphStore.updateNode(id, updates);
return;
}
// Check if this is a position update (node move)
if (updates.position) {
scheduleHistoryPush('Move Actor', 500); // Debounced for dragging
graphStore.updateNode(id, updates);
} else {
pushToHistory('Update Actor'); // Synchronous push BEFORE mutation
graphStore.updateNode(id, updates);
}
},
[graphStore, scheduleHistoryPush, pushToHistory]
);
const deleteNode = useCallback(
(id: string) => {
if (isRestoringRef.current) {
graphStore.deleteNode(id);
return;
}
const node = graphStore.nodes.find((n) => n.id === id);
const nodeType = graphStore.nodeTypes.find((nt) => nt.id === node?.data.type);
pushToHistory(`Delete ${nodeType?.label || 'Actor'}`); // Synchronous push BEFORE mutation
graphStore.deleteNode(id);
},
[graphStore, pushToHistory]
);
const addEdge = useCallback(
(edge: Relation) => {
if (isRestoringRef.current) {
graphStore.addEdge(edge);
return;
}
const edgeType = graphStore.edgeTypes.find((et) => et.id === edge.data?.type);
pushToHistory(`Add ${edgeType?.label || 'Relation'}`); // Synchronous push BEFORE mutation
graphStore.addEdge(edge);
},
[graphStore, pushToHistory]
);
const updateEdge = useCallback(
(id: string, data: Partial<RelationData>) => {
if (isRestoringRef.current) {
graphStore.updateEdge(id, data);
return;
}
pushToHistory('Update Relation'); // Synchronous push BEFORE mutation
graphStore.updateEdge(id, data);
},
[graphStore, pushToHistory]
);
const deleteEdge = useCallback(
(id: string) => {
if (isRestoringRef.current) {
graphStore.deleteEdge(id);
return;
}
const edge = graphStore.edges.find((e) => e.id === id);
const edgeType = graphStore.edgeTypes.find((et) => et.id === edge?.data?.type);
pushToHistory(`Delete ${edgeType?.label || 'Relation'}`); // Synchronous push BEFORE mutation
graphStore.deleteEdge(id);
},
[graphStore, pushToHistory]
);
const addNodeType = useCallback(
(nodeType: NodeTypeConfig) => {
if (isRestoringRef.current) {
graphStore.addNodeType(nodeType);
return;
}
pushToHistory(`Add Node Type: ${nodeType.label}`); // Synchronous push BEFORE mutation
graphStore.addNodeType(nodeType);
},
[graphStore, pushToHistory]
);
const updateNodeType = useCallback(
(id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) => {
if (isRestoringRef.current) {
graphStore.updateNodeType(id, updates);
return;
}
pushToHistory('Update Node Type'); // Synchronous push BEFORE mutation
graphStore.updateNodeType(id, updates);
},
[graphStore, pushToHistory]
);
const deleteNodeType = useCallback(
(id: string) => {
if (isRestoringRef.current) {
graphStore.deleteNodeType(id);
return;
}
const nodeType = graphStore.nodeTypes.find((nt) => nt.id === id);
pushToHistory(`Delete Node Type: ${nodeType?.label || id}`); // Synchronous push BEFORE mutation
graphStore.deleteNodeType(id);
},
[graphStore, pushToHistory]
);
const addEdgeType = useCallback(
(edgeType: EdgeTypeConfig) => {
if (isRestoringRef.current) {
graphStore.addEdgeType(edgeType);
return;
}
pushToHistory(`Add Edge Type: ${edgeType.label}`); // Synchronous push BEFORE mutation
graphStore.addEdgeType(edgeType);
},
[graphStore, pushToHistory]
);
const updateEdgeType = useCallback(
(id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => {
if (isRestoringRef.current) {
graphStore.updateEdgeType(id, updates);
return;
}
pushToHistory('Update Edge Type'); // Synchronous push BEFORE mutation
graphStore.updateEdgeType(id, updates);
},
[graphStore, pushToHistory]
);
const deleteEdgeType = useCallback(
(id: string) => {
if (isRestoringRef.current) {
graphStore.deleteEdgeType(id);
return;
}
const edgeType = graphStore.edgeTypes.find((et) => et.id === id);
pushToHistory(`Delete Edge Type: ${edgeType?.label || id}`); // Synchronous push BEFORE mutation
graphStore.deleteEdgeType(id);
},
[graphStore, pushToHistory]
);
const clearGraph = useCallback(
() => {
if (isRestoringRef.current) {
graphStore.clearGraph();
return;
}
pushToHistory('Clear Graph'); // Synchronous push BEFORE mutation
graphStore.clearGraph();
},
[graphStore, pushToHistory]
);
return {
// Wrapped operations with history
addNode,
updateNode,
deleteNode,
addEdge,
updateEdge,
deleteEdge,
addNodeType,
updateNodeType,
deleteNodeType,
addEdgeType,
updateEdgeType,
deleteEdgeType,
clearGraph,
// Pass through read-only operations
nodes: graphStore.nodes,
edges: graphStore.edges,
nodeTypes: graphStore.nodeTypes,
edgeTypes: graphStore.edgeTypes,
setNodes: graphStore.setNodes,
setEdges: graphStore.setEdges,
setNodeTypes: graphStore.setNodeTypes,
setEdgeTypes: graphStore.setEdgeTypes,
exportToFile: graphStore.exportToFile,
importFromFile: graphStore.importFromFile,
loadGraphState: graphStore.loadGraphState,
// Expose flag for detecting restore operations
isRestoringRef,
};
}

View file

@ -0,0 +1,192 @@
import { useEffect, useCallback, useRef } from 'react';
/**
* Keyboard Shortcut Manager
*
* Centralized system for managing keyboard shortcuts across the application.
* Provides:
* - Conflict detection and prevention
* - Priority-based shortcut handling
* - Platform-aware modifier keys (Cmd on Mac, Ctrl elsewhere)
* - Easy registration and unregistration of shortcuts
* - Shortcut documentation for help UI
*/
export interface KeyboardShortcut {
/** Unique identifier for this shortcut */
id: string;
/** Human-readable description */
description: string;
/** Key combination (e.g., 'n', 'Tab', 'z') */
key: string;
/** Requires Ctrl (Windows/Linux) or Cmd (Mac) */
ctrl?: boolean;
/** Requires Shift */
shift?: boolean;
/** Requires Alt/Option */
alt?: boolean;
/** Handler function */
handler: () => void;
/** Priority (higher number = higher priority, default: 0) */
priority?: number;
/** Category for grouping in help UI */
category: ShortcutCategory;
/** Whether shortcut is currently enabled */
enabled?: boolean;
}
export type ShortcutCategory =
| 'Document Management'
| 'Graph Editing'
| 'View'
| 'Selection'
| 'Navigation';
interface KeyboardShortcutManager {
/** Register a new keyboard shortcut */
register: (shortcut: KeyboardShortcut) => void;
/** Unregister a keyboard shortcut by id */
unregister: (id: string) => void;
/** Get all registered shortcuts */
getAllShortcuts: () => KeyboardShortcut[];
/** Get shortcuts by category */
getShortcutsByCategory: (category: ShortcutCategory) => KeyboardShortcut[];
/** Format shortcut for display (e.g., "Ctrl+N") */
formatShortcut: (shortcut: KeyboardShortcut) => string;
/** Check if shortcut is on Mac platform */
isMac: boolean;
}
/**
* Hook for centralized keyboard shortcut management
*
* Usage:
* ```tsx
* const shortcuts = useKeyboardShortcutManager();
*
* useEffect(() => {
* shortcuts.register({
* id: 'new-document',
* description: 'Create new document',
* key: 'n',
* ctrl: true,
* handler: () => createDocument(),
* category: 'Document Management'
* });
* return () => shortcuts.unregister('new-document');
* }, []);
* ```
*/
export function useKeyboardShortcutManager(): KeyboardShortcutManager {
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const register = useCallback((shortcut: KeyboardShortcut) => {
const shortcuts = shortcutsRef.current;
// Check for conflicts
const conflict = Array.from(shortcuts.values()).find(
existing =>
existing.key === shortcut.key &&
existing.ctrl === shortcut.ctrl &&
existing.shift === shortcut.shift &&
existing.alt === shortcut.alt &&
existing.id !== shortcut.id
);
if (conflict) {
console.warn(
`Keyboard shortcut conflict detected: "${shortcut.id}" conflicts with "${conflict.id}"`,
`Both use: ${formatShortcutInternal(shortcut, isMac)}`
);
}
shortcuts.set(shortcut.id, { ...shortcut, enabled: shortcut.enabled !== false });
}, [isMac]);
const unregister = useCallback((id: string) => {
shortcutsRef.current.delete(id);
}, []);
const getAllShortcuts = useCallback(() => {
return Array.from(shortcutsRef.current.values());
}, []);
const getShortcutsByCategory = useCallback((category: ShortcutCategory) => {
return Array.from(shortcutsRef.current.values()).filter(
shortcut => shortcut.category === category
);
}, []);
const formatShortcut = useCallback((shortcut: KeyboardShortcut) => {
return formatShortcutInternal(shortcut, isMac);
}, [isMac]);
// Global event listener
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const shortcuts = shortcutsRef.current;
const modifier = isMac ? e.metaKey : e.ctrlKey;
// Find matching shortcuts
const matches = Array.from(shortcuts.values()).filter(shortcut => {
if (!shortcut.enabled) return false;
const keyMatch = e.key.toLowerCase() === shortcut.key.toLowerCase();
const ctrlMatch = shortcut.ctrl ? modifier : !e.ctrlKey && !e.metaKey;
const shiftMatch = shortcut.shift ? e.shiftKey : !e.shiftKey;
const altMatch = shortcut.alt ? e.altKey : !e.altKey;
return keyMatch && ctrlMatch && shiftMatch && altMatch;
});
if (matches.length > 0) {
// Sort by priority (higher first)
matches.sort((a, b) => (b.priority || 0) - (a.priority || 0));
// Execute highest priority handler
e.preventDefault();
matches[0].handler();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isMac]);
return {
register,
unregister,
getAllShortcuts,
getShortcutsByCategory,
formatShortcut,
isMac,
};
}
/**
* Helper function to format shortcut for display
*/
function formatShortcutInternal(shortcut: KeyboardShortcut, isMac: boolean): string {
const parts: string[] = [];
if (shortcut.ctrl) {
parts.push(isMac ? 'Cmd' : 'Ctrl');
}
if (shortcut.shift) {
parts.push('Shift');
}
if (shortcut.alt) {
parts.push(isMac ? 'Option' : 'Alt');
}
// Format key name
let keyName = shortcut.key;
if (keyName === ' ') keyName = 'Space';
else if (keyName.length === 1) keyName = keyName.toUpperCase();
else keyName = keyName.charAt(0).toUpperCase() + keyName.slice(1);
parts.push(keyName);
return parts.join('+');
}

View file

@ -0,0 +1,119 @@
import { useEffect } from 'react';
import { useWorkspaceStore } from '../stores/workspaceStore';
/**
* useKeyboardShortcuts Hook
*
* Global keyboard shortcuts for the application:
* - Ctrl/Cmd + Tab: Next document
* - Ctrl/Cmd + Shift + Tab: Previous document
* - Ctrl/Cmd + W: Close current document
* - Ctrl/Cmd + N: New document
* - Ctrl/Cmd + S: Save current document (trigger save immediately)
* - Ctrl/Cmd + O: Open document manager
* - Ctrl/Cmd + Z: Undo (per-document)
* - Ctrl/Cmd + Y / Ctrl/Cmd + Shift + Z: Redo (per-document)
*/
interface UseKeyboardShortcutsOptions {
onOpenDocumentManager?: () => void;
onUndo?: () => void;
onRedo?: () => void;
}
export function useKeyboardShortcuts(options?: UseKeyboardShortcutsOptions) {
const {
documentOrder,
activeDocumentId,
switchToDocument,
closeDocument,
createDocument,
saveDocument,
} = useWorkspaceStore();
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modifier = isMac ? e.metaKey : e.ctrlKey;
// Ctrl/Cmd + Tab - Next document
if (modifier && e.key === 'Tab' && !e.shiftKey) {
e.preventDefault();
const currentIndex = documentOrder.findIndex(id => id === activeDocumentId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % documentOrder.length;
switchToDocument(documentOrder[nextIndex]);
}
return;
}
// Ctrl/Cmd + Shift + Tab - Previous document
if (modifier && e.key === 'Tab' && e.shiftKey) {
e.preventDefault();
const currentIndex = documentOrder.findIndex(id => id === activeDocumentId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + documentOrder.length) % documentOrder.length;
switchToDocument(documentOrder[prevIndex]);
}
return;
}
// Ctrl/Cmd + W - Close current document
if (modifier && e.key === 'w') {
e.preventDefault();
if (activeDocumentId && documentOrder.length > 1) {
closeDocument(activeDocumentId);
}
return;
}
// Ctrl/Cmd + N - New document
if (modifier && e.key === 'n') {
e.preventDefault();
createDocument();
return;
}
// Ctrl/Cmd + S - Save current document
if (modifier && e.key === 's') {
e.preventDefault();
if (activeDocumentId) {
saveDocument(activeDocumentId);
}
return;
}
// Ctrl/Cmd + O - Open document manager
if (modifier && e.key === 'o') {
e.preventDefault();
options?.onOpenDocumentManager?.();
return;
}
// Ctrl/Cmd + Z - Undo
if (modifier && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
options?.onUndo?.();
return;
}
// Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z - Redo
if (modifier && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
options?.onRedo?.();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
documentOrder,
activeDocumentId,
switchToDocument,
closeDocument,
createDocument,
saveDocument,
options,
]);
}

10
src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './styles/index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

29
src/stores/editorStore.ts Normal file
View file

@ -0,0 +1,29 @@
import { create } from 'zustand';
import type { EditorSettings, EditorActions } from '../types';
const defaultSettings: EditorSettings = {
snapToGrid: false,
showGrid: true,
gridSize: 15,
panOnDrag: true,
zoomOnScroll: true,
};
interface EditorStore extends EditorSettings {
selectedRelationType: string | null;
setSelectedRelationType: (typeId: string) => void;
}
export const useEditorStore = create<EditorStore & EditorActions>((set) => ({
...defaultSettings,
selectedRelationType: null,
updateSettings: (settings: Partial<EditorSettings>) =>
set((state) => ({
...state,
...settings,
})),
setSelectedRelationType: (typeId: string) =>
set({ selectedRelationType: typeId }),
}));

227
src/stores/graphStore.ts Normal file
View file

@ -0,0 +1,227 @@
import { create } from 'zustand';
import { addEdge as rfAddEdge } from 'reactflow';
import type {
Actor,
Relation,
NodeTypeConfig,
EdgeTypeConfig,
RelationData,
GraphActions
} from '../types';
import { persistenceMiddleware } from './persistence/middleware';
import { loadGraphState } from './persistence/loader';
import { exportGraphToFile, selectFileForImport } from './persistence/fileIO';
/**
* IMPORTANT: DO NOT USE THIS STORE DIRECTLY IN COMPONENTS
*
* This is the low-level graph store. All mutation operations should go through
* the `useGraphWithHistory` hook to ensure undo/redo history is tracked.
*
* CORRECT: Use `useGraphWithHistory()` in components
* WRONG: Use `useGraphStore()` directly (bypasses history tracking)
*
* Exception: Read-only access in presentation components (CustomNode, CustomEdge)
* is acceptable.
*
* See: src/hooks/useGraphWithHistory.ts
*/
interface GraphStore {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}
// Default node types
const defaultNodeTypes: NodeTypeConfig[] = [
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' },
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' },
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' },
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' },
];
// Default edge types
const defaultEdgeTypes: EdgeTypeConfig[] = [
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },
{ id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' },
{ id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' },
{ id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' },
];
// Load initial state from localStorage or use defaults
const loadInitialState = (): GraphStore => {
const savedState = loadGraphState();
if (savedState) {
return {
nodes: savedState.nodes,
edges: savedState.edges,
nodeTypes: savedState.nodeTypes,
edgeTypes: savedState.edgeTypes,
};
}
return {
nodes: [],
edges: [],
nodeTypes: defaultNodeTypes,
edgeTypes: defaultEdgeTypes,
};
};
const initialState = loadInitialState();
export const useGraphStore = create<GraphStore & GraphActions>(
persistenceMiddleware((set) => ({
nodes: initialState.nodes,
edges: initialState.edges,
nodeTypes: initialState.nodeTypes,
edgeTypes: initialState.edgeTypes,
// Node operations
addNode: (node: Actor) =>
set((state) => ({
nodes: [...state.nodes, node],
})),
updateNode: (id: string, updates: Partial<Actor>) =>
set((state) => ({
nodes: state.nodes.map((node) =>
node.id === id
? {
...node,
...updates,
data: updates.data ? { ...node.data, ...updates.data } : node.data,
}
: node
),
})),
deleteNode: (id: string) =>
set((state) => ({
nodes: state.nodes.filter((node) => node.id !== id),
edges: state.edges.filter(
(edge) => edge.source !== id && edge.target !== id
),
})),
// Edge operations
addEdge: (edge: Relation) =>
set((state) => ({
edges: rfAddEdge(edge, state.edges) as Relation[],
})),
updateEdge: (id: string, data: Partial<RelationData>) =>
set((state) => ({
edges: state.edges.map((edge) =>
edge.id === id
? { ...edge, data: { ...edge.data, ...data } as RelationData }
: edge
),
})),
deleteEdge: (id: string) =>
set((state) => ({
edges: state.edges.filter((edge) => edge.id !== id),
})),
// Node type operations
addNodeType: (nodeType: NodeTypeConfig) =>
set((state) => ({
nodeTypes: [...state.nodeTypes, nodeType],
})),
updateNodeType: (id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) =>
set((state) => ({
nodeTypes: state.nodeTypes.map((type) =>
type.id === id ? { ...type, ...updates } : type
),
})),
deleteNodeType: (id: string) =>
set((state) => ({
nodeTypes: state.nodeTypes.filter((type) => type.id !== id),
})),
// Edge type operations
addEdgeType: (edgeType: EdgeTypeConfig) =>
set((state) => ({
edgeTypes: [...state.edgeTypes, edgeType],
})),
updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) =>
set((state) => ({
edgeTypes: state.edgeTypes.map((type) =>
type.id === id ? { ...type, ...updates } : type
),
})),
deleteEdgeType: (id: string) =>
set((state) => ({
edgeTypes: state.edgeTypes.filter((type) => type.id !== id),
})),
// Utility operations
clearGraph: () =>
set({
nodes: [],
edges: [],
}),
setNodes: (nodes: Actor[]) =>
set({
nodes,
}),
setEdges: (edges: Relation[]) =>
set({
edges,
}),
setNodeTypes: (nodeTypes: NodeTypeConfig[]) =>
set({
nodeTypes,
}),
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) =>
set({
edgeTypes,
}),
// File import/export operations
exportToFile: () => {
const state = useGraphStore.getState();
exportGraphToFile(state.nodes, state.edges, state.nodeTypes, state.edgeTypes);
},
importFromFile: (onError?: (error: string) => void) => {
selectFileForImport(
(data) => {
// Load the imported data into the store
set({
nodes: data.nodes,
edges: data.edges,
nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes,
});
},
(error) => {
console.error('Import failed:', error);
if (onError) {
onError(error);
} else {
alert(`Failed to import file: ${error}`);
}
}
);
},
loadGraphState: (data) =>
set({
nodes: data.nodes,
edges: data.edges,
nodeTypes: data.nodeTypes,
edgeTypes: data.edgeTypes,
}),
})));

333
src/stores/historyStore.ts Normal file
View file

@ -0,0 +1,333 @@
import { create } from 'zustand';
import type { ConstellationDocument } from './persistence/types';
import { useGraphStore } from './graphStore';
/**
* History Store - Per-Document Undo/Redo System
*
* Each document maintains its own independent history stack with a maximum of 50 actions.
* Tracks all reversible operations: node add/delete/move, edge add/delete/edit, type changes.
*
* IMPORTANT: History is per-document. Each document has completely separate undo/redo stacks.
*/
export interface HistoryAction {
description: string; // Human-readable description (e.g., "Add Person Actor", "Delete Collaborates Relation")
timestamp: number; // When the action occurred
documentState: ConstellationDocument; // Complete document state after this action
}
export interface DocumentHistory {
undoStack: HistoryAction[]; // Past states to restore (most recent at end)
redoStack: HistoryAction[]; // Future states to restore (most recent at end)
}
interface HistoryStore {
// Map of documentId -> history (each document has its own independent history)
histories: Map<string, DocumentHistory>;
// Max number of actions to keep in history per document
maxHistorySize: number;
}
interface HistoryActions {
// Initialize history for a document
initializeHistory: (documentId: string, initialState: ConstellationDocument) => void;
// Push a new action onto the document's history stack
pushAction: (documentId: string, action: HistoryAction) => void;
// Undo the last action for a specific document
undo: (documentId: string) => ConstellationDocument | null;
// Redo the last undone action for a specific document
redo: (documentId: string) => ConstellationDocument | null;
// Check if undo is available for a document
canUndo: (documentId: string) => boolean;
// Check if redo is available for a document
canRedo: (documentId: string) => boolean;
// Get the description of the next undo action for a document
getUndoDescription: (documentId: string) => string | null;
// Get the description of the next redo action for a document
getRedoDescription: (documentId: string) => string | null;
// Clear history for a specific document
clearHistory: (documentId: string) => void;
// Remove history for a document (when document is deleted)
removeHistory: (documentId: string) => void;
// Get history stats for debugging
getHistoryStats: (documentId: string) => {
undoCount: number;
redoCount: number;
} | null;
}
const MAX_HISTORY_SIZE = 50;
export const useHistoryStore = create<HistoryStore & HistoryActions>((set, get) => ({
histories: new Map(),
maxHistorySize: MAX_HISTORY_SIZE,
initializeHistory: (documentId: string, _initialState: ConstellationDocument) => {
set((state) => {
const newHistories = new Map(state.histories);
// Only initialize if not already present
if (!newHistories.has(documentId)) {
newHistories.set(documentId, {
undoStack: [],
redoStack: [],
});
}
return { histories: newHistories };
});
},
pushAction: (documentId: string, action: HistoryAction) => {
set((state) => {
const newHistories = new Map(state.histories);
const history = newHistories.get(documentId);
if (!history) {
console.warn(`History not initialized for document ${documentId}`);
return {};
}
console.log('📝 pushAction:', {
description: action.description,
actionStateNodes: action.documentState.graph.nodes.length,
actionStateEdges: action.documentState.graph.edges.length,
currentUndoStackSize: history.undoStack.length,
});
// The action.documentState contains the state BEFORE the action was performed
// We push this to the undo stack so we can restore it if the user clicks undo
const newUndoStack = [...history.undoStack];
newUndoStack.push({
description: action.description,
timestamp: action.timestamp,
documentState: JSON.parse(JSON.stringify(action.documentState)), // Deep copy
});
// Trim undo stack if it exceeds max size
if (newUndoStack.length > state.maxHistorySize) {
newUndoStack.shift(); // Remove oldest
}
// Clear redo stack when a new action is performed (can't redo after new action)
const newRedoStack: HistoryAction[] = [];
newHistories.set(documentId, {
undoStack: newUndoStack,
redoStack: newRedoStack,
});
console.log('📝 after push:', {
description: action.description,
newUndoStackSize: newUndoStack.length,
topOfStackNodes: newUndoStack[newUndoStack.length - 1]?.documentState.graph.nodes.length,
topOfStackEdges: newUndoStack[newUndoStack.length - 1]?.documentState.graph.edges.length,
});
return { histories: newHistories };
});
},
undo: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
if (!history || history.undoStack.length === 0) {
return null;
}
console.log('⏪ undo:', {
description: history.undoStack[history.undoStack.length - 1].description,
undoStackSize: history.undoStack.length,
});
const newHistories = new Map(state.histories);
// Pop the last action from undo stack - this is the state BEFORE the action
const lastAction = history.undoStack[history.undoStack.length - 1];
const newUndoStack = history.undoStack.slice(0, -1);
// Get current state from graphStore and push it to redo stack
const currentGraphState = useGraphStore.getState();
const currentStateSnapshot = {
graph: {
nodes: currentGraphState.nodes,
edges: currentGraphState.edges,
nodeTypes: currentGraphState.nodeTypes,
edgeTypes: currentGraphState.edgeTypes,
},
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
version: '1.0' as const,
};
const newRedoStack = [...history.redoStack];
newRedoStack.push({
description: lastAction.description,
timestamp: Date.now(),
documentState: JSON.parse(JSON.stringify(currentStateSnapshot)), // Deep copy
});
// Restore the previous state (deep copy)
const restoredState = JSON.parse(JSON.stringify(lastAction.documentState));
console.log('⏪ after undo:', {
restoredStateNodes: restoredState.graph.nodes.length,
restoredStateEdges: restoredState.graph.edges.length,
undoStackSize: newUndoStack.length,
redoStackSize: newRedoStack.length,
});
newHistories.set(documentId, {
undoStack: newUndoStack,
redoStack: newRedoStack,
});
set({ histories: newHistories });
return restoredState;
},
redo: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
if (!history || history.redoStack.length === 0) {
return null;
}
const newHistories = new Map(state.histories);
// Pop the last action from redo stack
const lastAction = history.redoStack[history.redoStack.length - 1];
const newRedoStack = history.redoStack.slice(0, -1);
// Get current state from graphStore and push it to undo stack
const currentGraphState = useGraphStore.getState();
const currentStateSnapshot = {
graph: {
nodes: currentGraphState.nodes,
edges: currentGraphState.edges,
nodeTypes: currentGraphState.nodeTypes,
edgeTypes: currentGraphState.edgeTypes,
},
metadata: {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
version: '1.0' as const,
};
const newUndoStack = [...history.undoStack];
newUndoStack.push({
description: lastAction.description,
timestamp: Date.now(),
documentState: JSON.parse(JSON.stringify(currentStateSnapshot)), // Deep copy
});
// Trim if exceeds max size
if (newUndoStack.length > state.maxHistorySize) {
newUndoStack.shift(); // Remove oldest
}
// Restore the future state (deep copy)
const restoredState = JSON.parse(JSON.stringify(lastAction.documentState));
newHistories.set(documentId, {
undoStack: newUndoStack,
redoStack: newRedoStack,
});
set({ histories: newHistories });
return restoredState;
},
canUndo: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
return history ? history.undoStack.length > 0 : false;
},
canRedo: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
return history ? history.redoStack.length > 0 : false;
},
getUndoDescription: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
if (!history || history.undoStack.length === 0) {
return null;
}
const lastAction = history.undoStack[history.undoStack.length - 1];
return lastAction.description;
},
getRedoDescription: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
if (!history || history.redoStack.length === 0) {
return null;
}
const lastAction = history.redoStack[history.redoStack.length - 1];
return lastAction.description;
},
clearHistory: (documentId: string) => {
set((state) => {
const newHistories = new Map(state.histories);
const history = newHistories.get(documentId);
if (history) {
newHistories.set(documentId, {
undoStack: [],
redoStack: [],
});
}
return { histories: newHistories };
});
},
removeHistory: (documentId: string) => {
set((state) => {
const newHistories = new Map(state.histories);
newHistories.delete(documentId);
return { histories: newHistories };
});
},
getHistoryStats: (documentId: string) => {
const state = get();
const history = state.histories.get(documentId);
if (!history) {
return null;
}
return {
undoCount: history.undoStack.length,
redoCount: history.redoStack.length,
};
},
}));

View file

@ -0,0 +1,26 @@
/**
* Persistence Constants
*
* Storage keys, configuration, and version information for local storage persistence
*/
// Storage keys for localStorage
export const STORAGE_KEYS = {
GRAPH_STATE: 'constellation:graph:v1',
EDITOR_SETTINGS: 'constellation:editor:v1',
AUTOSAVE_FLAG: 'constellation:autosave',
LAST_SAVED: 'constellation:lastSaved',
} as const;
// Debounce configuration for auto-save
export const DEBOUNCE_CONFIG = {
DELAY: 1000, // 1 second after last change
MAX_WAIT: 5000, // Force save every 5 seconds
THROTTLE_NODE_DRAG: 500, // Faster saves during drag operations
} as const;
// Current schema version
export const SCHEMA_VERSION = '1.0.0';
// Application identifier
export const APP_NAME = 'constellation-analyzer';

View file

@ -0,0 +1,112 @@
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
import type { ConstellationDocument } from './types';
import { createDocument } from './saver';
import { validateDocument, deserializeGraphState } from './loader';
/**
* File I/O - Export and import ConstellationDocument to/from files
*/
/**
* Export current graph state to a JSON file
*/
export function exportGraphToFile(
nodes: Actor[],
edges: Relation[],
nodeTypes: NodeTypeConfig[],
edgeTypes: EdgeTypeConfig[]
): void {
// Create the document using the existing saver
const doc = createDocument(nodes, edges, nodeTypes, edgeTypes);
// Convert to JSON with pretty formatting
const jsonString = JSON.stringify(doc, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
// Create download link
const link = window.document.createElement('a');
link.href = url;
const dateStr = new Date().toISOString().slice(0, 10);
link.download = `constellation-analysis-${dateStr}.json`;
// Trigger download
window.document.body.appendChild(link);
link.click();
// Cleanup
window.document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Import graph state from a JSON file
*/
export function importGraphFromFile(
file: File,
onSuccess: (data: {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}) => void,
onError: (error: string) => void
): void {
const reader = new FileReader();
reader.onload = (event) => {
try {
const jsonString = event.target?.result as string;
const parsed = JSON.parse(jsonString);
// Validate using the existing loader validation
if (!validateDocument(parsed)) {
throw new Error('Invalid file format: File does not match expected Constellation Analyzer format');
}
// Deserialize the graph state
const graphState = deserializeGraphState(parsed as ConstellationDocument);
if (!graphState) {
throw new Error('Failed to parse graph data from file');
}
onSuccess(graphState);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred while importing file';
onError(errorMessage);
}
};
reader.onerror = () => {
onError('Failed to read file');
};
reader.readAsText(file);
}
/**
* Trigger file selection dialog for import
*/
export function selectFileForImport(
onSuccess: (data: {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}) => void,
onError: (error: string) => void
): void {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (event) => {
const file = (event.target as HTMLInputElement).files?.[0];
if (file) {
importGraphFromFile(file, onSuccess, onError);
}
};
input.click();
}

View file

@ -0,0 +1,202 @@
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
/**
* Loader - Handles loading and validating data from localStorage
*/
// Validate document structure
export function validateDocument(doc: unknown): doc is ConstellationDocument {
// Type guard: ensure doc is an object
if (!doc || typeof doc !== 'object') {
return false;
}
const document = doc as Record<string, unknown>;
// Check metadata
if (!document.metadata ||
typeof document.metadata !== 'object' ||
document.metadata === null) {
return false;
}
const metadata = document.metadata as Record<string, unknown>;
if (typeof metadata.version !== 'string' ||
typeof metadata.appName !== 'string' ||
typeof metadata.createdAt !== 'string' ||
typeof metadata.updatedAt !== 'string') {
return false;
}
// Check app name
if (metadata.appName !== APP_NAME) {
console.warn('Document from different app:', metadata.appName);
return false;
}
// Check graph structure
if (!document.graph ||
typeof document.graph !== 'object' ||
document.graph === null) {
return false;
}
const graph = document.graph as Record<string, unknown>;
if (!Array.isArray(graph.nodes) ||
!Array.isArray(graph.edges) ||
!Array.isArray(graph.nodeTypes) ||
!Array.isArray(graph.edgeTypes)) {
return false;
}
// Validate nodes
for (const node of graph.nodes) {
if (!node || typeof node !== 'object') {
return false;
}
const n = node as Record<string, unknown>;
if (!n.id || !n.type || !n.position || !n.data) {
return false;
}
const pos = n.position as Record<string, unknown>;
if (typeof pos.x !== 'number' || typeof pos.y !== 'number') {
return false;
}
}
// Validate edges
for (const edge of graph.edges) {
if (!edge || typeof edge !== 'object') {
return false;
}
const e = edge as Record<string, unknown>;
if (!e.id || !e.source || !e.target) {
return false;
}
}
// Validate node types
for (const nodeType of graph.nodeTypes) {
if (!nodeType || typeof nodeType !== 'object') {
return false;
}
const nt = nodeType as Record<string, unknown>;
if (!nt.id || !nt.label || !nt.color) {
return false;
}
}
// Validate edge types
for (const edgeType of graph.edgeTypes) {
if (!edgeType || typeof edgeType !== 'object') {
return false;
}
const et = edgeType as Record<string, unknown>;
if (!et.id || !et.label || !et.color) {
return false;
}
}
return true;
}
// Deserialize actors (add back React Flow properties and initialize transient UI state)
function deserializeActors(serializedActors: SerializedActor[]): Actor[] {
return serializedActors.map(node => ({
...node,
// Initialize transient UI state (not persisted)
selected: false,
dragging: false,
})) as Actor[];
}
// Deserialize relations (add back React Flow properties)
function deserializeRelations(serializedRelations: SerializedRelation[]): Relation[] {
return serializedRelations.map(edge => ({
...edge,
})) as Relation[];
}
// Load document from localStorage
export function loadDocument(): ConstellationDocument | null {
try {
const json = localStorage.getItem(STORAGE_KEYS.GRAPH_STATE);
if (!json) {
console.log('No saved state found');
return null;
}
const parsed = JSON.parse(json);
if (!validateDocument(parsed)) {
console.error('Invalid document structure');
return null;
}
// Check version compatibility
if (parsed.metadata.version !== SCHEMA_VERSION) {
console.warn(`Version mismatch: ${parsed.metadata.version} vs ${SCHEMA_VERSION}`);
// TODO: Implement migration in Phase 3
// For now, we'll try to load it anyway
}
return parsed;
} catch (error) {
console.error('Failed to load document:', error);
return null;
}
}
// Deserialize graph state from a document
export function deserializeGraphState(document: ConstellationDocument): {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
} | null {
try {
const nodes = deserializeActors(document.graph.nodes);
const edges = deserializeRelations(document.graph.edges);
return {
nodes,
edges,
nodeTypes: document.graph.nodeTypes,
edgeTypes: document.graph.edgeTypes,
};
} catch (error) {
console.error('Failed to deserialize graph state:', error);
return null;
}
}
// Load and hydrate graph state
export function loadGraphState(): {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
} | null {
const document = loadDocument();
if (!document) {
return null;
}
return deserializeGraphState(document);
}
// Check if saved state exists
export function hasSavedState(): boolean {
return localStorage.getItem(STORAGE_KEYS.GRAPH_STATE) !== null;
}
// Get last saved timestamp
export function getLastSavedTimestamp(): string | null {
return localStorage.getItem(STORAGE_KEYS.LAST_SAVED);
}

View file

@ -0,0 +1,41 @@
import type { StateCreator } from 'zustand';
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
import { debouncedSave } from './saver';
import { DEBOUNCE_CONFIG } from './constants';
/**
* Persistence Middleware - Auto-saves graph state to localStorage
*
* This middleware intercepts state changes in the Zustand store and
* triggers debounced saves to localStorage.
*/
export const persistenceMiddleware = <
T extends {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}
>(
config: StateCreator<T>
): StateCreator<T> => (set, get, api) => {
const stateCreator = config(set, get, api);
api.subscribe((state) => {
if (state.nodes && state.edges && state.nodeTypes && state.edgeTypes) {
debouncedSave(
state.nodes,
state.edges,
state.nodeTypes,
state.edgeTypes,
DEBOUNCE_CONFIG.DELAY,
DEBOUNCE_CONFIG.MAX_WAIT
);
}
});
return stateCreator;
};

View file

@ -0,0 +1,144 @@
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from '../../types';
import type { ConstellationDocument, SerializedActor, SerializedRelation } from './types';
import { STORAGE_KEYS, SCHEMA_VERSION, APP_NAME } from './constants';
/**
* Saver - Handles serialization and saving to localStorage
*/
// Serialize a single actor (node) for storage
// Excludes transient UI state like selected and dragging
function serializeActor(actor: Actor): SerializedActor {
return {
id: actor.id,
type: actor.type ?? 'default',
position: actor.position,
data: actor.data,
};
}
// Serialize a single relation (edge) for storage
function serializeRelation(relation: Relation): SerializedRelation {
return {
id: relation.id,
source: relation.source,
target: relation.target,
type: relation.type,
data: relation.data,
sourceHandle: relation.sourceHandle,
targetHandle: relation.targetHandle,
};
}
// Create a complete document from current state
export function createDocument(
nodes: Actor[],
edges: Relation[],
nodeTypes: NodeTypeConfig[],
edgeTypes: EdgeTypeConfig[],
existingDocument?: ConstellationDocument
): ConstellationDocument {
const now = new Date().toISOString();
return {
metadata: {
version: SCHEMA_VERSION,
appName: APP_NAME,
createdAt: existingDocument?.metadata.createdAt || now,
updatedAt: now,
lastSavedBy: 'browser',
},
graph: {
nodes: nodes.map(serializeActor),
edges: edges.map(serializeRelation),
nodeTypes,
edgeTypes,
},
};
}
// Save document to localStorage
export function saveDocument(document: ConstellationDocument): boolean {
try {
const json = JSON.stringify(document);
localStorage.setItem(STORAGE_KEYS.GRAPH_STATE, json);
localStorage.setItem(STORAGE_KEYS.LAST_SAVED, document.metadata.updatedAt);
return true;
} catch (error) {
if (error instanceof Error && error.name === 'QuotaExceededError') {
console.error('Storage quota exceeded');
// TODO: Handle quota exceeded error in Phase 2
} else {
console.error('Failed to save document:', error);
}
return false;
}
}
// Save current graph state
export function saveGraphState(
nodes: Actor[],
edges: Relation[],
nodeTypes: NodeTypeConfig[],
edgeTypes: EdgeTypeConfig[]
): boolean {
try {
// Try to load existing document to preserve createdAt timestamp
const existingJson = localStorage.getItem(STORAGE_KEYS.GRAPH_STATE);
let existingDocument: ConstellationDocument | undefined;
if (existingJson) {
try {
existingDocument = JSON.parse(existingJson);
} catch {
// Ignore parse errors, we'll create a new document
}
}
const document = createDocument(nodes, edges, nodeTypes, edgeTypes, existingDocument);
return saveDocument(document);
} catch (error) {
console.error('Failed to save graph state:', error);
return false;
}
}
// Create a debounced save function
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
let lastSaveTime = 0;
export function debouncedSave(
nodes: Actor[],
edges: Relation[],
nodeTypes: NodeTypeConfig[],
edgeTypes: EdgeTypeConfig[],
delay: number = 1000,
maxWait: number = 5000
): void {
const now = Date.now();
// Clear existing timeout
if (saveTimeout) {
clearTimeout(saveTimeout);
}
// Force save if max wait time exceeded
if (now - lastSaveTime >= maxWait) {
saveGraphState(nodes, edges, nodeTypes, edgeTypes);
lastSaveTime = now;
return;
}
// Schedule debounced save
saveTimeout = setTimeout(() => {
saveGraphState(nodes, edges, nodeTypes, edgeTypes);
lastSaveTime = now;
saveTimeout = null;
}, delay);
}
// Clear saved state
export function clearSavedState(): void {
localStorage.removeItem(STORAGE_KEYS.GRAPH_STATE);
localStorage.removeItem(STORAGE_KEYS.LAST_SAVED);
}

View file

@ -0,0 +1,54 @@
import type { ActorData, RelationData, NodeTypeConfig, EdgeTypeConfig } from '../../types';
/**
* Persistence Types
*
* Type definitions for serializing and deserializing constellation data
*/
// Simplified node structure for storage (without React Flow internals)
export interface SerializedActor {
id: string;
type: string; // React Flow node type (e.g., "custom")
position: { x: number; y: number };
data: ActorData;
}
// Simplified edge structure for storage (without React Flow internals)
export interface SerializedRelation {
id: string;
source: string;
target: string;
type?: string; // React Flow edge type
data?: RelationData;
sourceHandle?: string | null;
targetHandle?: string | null;
}
// Complete document structure for storage
export interface ConstellationDocument {
metadata: {
version: string; // Schema version (e.g., "1.0.0")
appName: string; // "constellation-analyzer"
createdAt: string; // ISO timestamp
updatedAt: string; // ISO timestamp
lastSavedBy: string; // Browser fingerprint or "unknown"
documentId?: string; // NEW: Unique document ID (for workspace)
title?: string; // NEW: Document title (for workspace)
};
graph: {
nodes: SerializedActor[];
edges: SerializedRelation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
};
}
// Error types for persistence operations
export enum PersistenceError {
QUOTA_EXCEEDED = 'quota_exceeded',
CORRUPTED_DATA = 'corrupted_data',
VERSION_MISMATCH = 'version_mismatch',
PARSE_ERROR = 'parse_error',
STORAGE_UNAVAILABLE = 'storage_unavailable',
}

View file

@ -0,0 +1,95 @@
import type { ConstellationDocument } from '../persistence/types';
import type { WorkspaceState, WorkspaceSettings, DocumentMetadata } from './types';
import { loadDocument } from '../persistence/loader';
import {
WORKSPACE_STORAGE_KEYS,
generateWorkspaceId,
generateDocumentId,
saveWorkspaceState,
saveDocumentToStorage,
saveDocumentMetadata,
} from './persistence';
/**
* Migration from Single-Document to Multi-Document Workspace
*
* Converts legacy single-document format to new workspace format
*/
export function migrateToWorkspace(): WorkspaceState | null {
console.log('Checking for legacy data to migrate...');
// Check for legacy data
const legacyDoc = loadDocument();
if (!legacyDoc) {
console.log('No legacy data found');
return null;
}
console.log('Legacy data found, migrating to workspace format...');
try {
// Generate IDs
const workspaceId = generateWorkspaceId();
const documentId = generateDocumentId();
// Create document with new metadata
const migratedDoc: ConstellationDocument = {
...legacyDoc,
metadata: {
...legacyDoc.metadata,
documentId,
title: 'Imported Analysis',
},
};
// Create document metadata
const metadata: DocumentMetadata = {
id: documentId,
title: 'Imported Analysis',
isDirty: false,
lastModified: new Date().toISOString(),
};
// Create workspace settings from legacy document
const settings: WorkspaceSettings = {
maxOpenDocuments: 10,
autoSaveEnabled: true,
defaultNodeTypes: legacyDoc.graph.nodeTypes,
defaultEdgeTypes: legacyDoc.graph.edgeTypes,
recentFiles: [],
};
// Create workspace state
const workspace: WorkspaceState = {
workspaceId,
workspaceName: 'My Workspace',
documentOrder: [documentId],
activeDocumentId: documentId,
settings,
};
// Save to new format
saveWorkspaceState(workspace);
saveDocumentToStorage(documentId, migratedDoc);
saveDocumentMetadata(documentId, metadata);
// Remove legacy data
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.LEGACY_GRAPH_STATE);
localStorage.removeItem('constellation:lastSaved'); // Old timestamp key
console.log('Migration completed successfully');
return workspace;
} catch (error) {
console.error('Migration failed:', error);
return null;
}
}
// Check if migration is needed
export function needsMigration(): boolean {
const hasWorkspace = localStorage.getItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE) !== null;
const hasLegacyData = localStorage.getItem(WORKSPACE_STORAGE_KEYS.LEGACY_GRAPH_STATE) !== null;
return !hasWorkspace && hasLegacyData;
}

View file

@ -0,0 +1,188 @@
import type { ConstellationDocument } from '../persistence/types';
import type { WorkspaceState, DocumentMetadata } from './types';
import { validateDocument } from '../persistence/loader';
/**
* Workspace Persistence
*
* Handles saving/loading workspace and documents to/from localStorage
*/
// Storage keys for workspace
export const WORKSPACE_STORAGE_KEYS = {
WORKSPACE_STATE: 'constellation:workspace:v1',
WORKSPACE_SETTINGS: 'constellation:workspace:settings:v1',
DOCUMENT_PREFIX: 'constellation:document:v1:',
DOCUMENT_METADATA_PREFIX: 'constellation:meta:v1:',
// Legacy key for migration
LEGACY_GRAPH_STATE: 'constellation:graph:v1',
} as const;
// Generate unique workspace ID
export function generateWorkspaceId(): string {
return `workspace_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Generate unique document ID
export function generateDocumentId(): string {
return `doc_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
// Save workspace state to localStorage
export function saveWorkspaceState(state: WorkspaceState): boolean {
try {
localStorage.setItem(
WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE,
JSON.stringify(state)
);
return true;
} catch (error) {
console.error('Failed to save workspace state:', error);
return false;
}
}
// Load workspace state from localStorage
export function loadWorkspaceState(): WorkspaceState | null {
try {
const json = localStorage.getItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE);
if (!json) return null;
const state = JSON.parse(json) as WorkspaceState;
return state;
} catch (error) {
console.error('Failed to load workspace state:', error);
return null;
}
}
// Save document to localStorage
export function saveDocumentToStorage(
documentId: string,
document: ConstellationDocument
): boolean {
try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`;
localStorage.setItem(key, JSON.stringify(document));
return true;
} catch (error) {
console.error(`Failed to save document ${documentId}:`, error);
return false;
}
}
// Load document from localStorage
export function loadDocumentFromStorage(documentId: string): ConstellationDocument | null {
try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`;
const json = localStorage.getItem(key);
if (!json) return null;
const doc = JSON.parse(json);
// Validate document structure
if (!validateDocument(doc)) {
console.error(`Invalid document structure for ${documentId}`);
return null;
}
return doc as ConstellationDocument;
} catch (error) {
console.error(`Failed to load document ${documentId}:`, error);
return null;
}
}
// Delete document from localStorage
export function deleteDocumentFromStorage(documentId: string): boolean {
try {
const docKey = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX}${documentId}`;
const metaKey = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`;
localStorage.removeItem(docKey);
localStorage.removeItem(metaKey);
return true;
} catch (error) {
console.error(`Failed to delete document ${documentId}:`, error);
return false;
}
}
// Save document metadata
export function saveDocumentMetadata(
documentId: string,
metadata: DocumentMetadata
): boolean {
try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`;
localStorage.setItem(key, JSON.stringify(metadata));
return true;
} catch (error) {
console.error(`Failed to save metadata for ${documentId}:`, error);
return false;
}
}
// Load document metadata
export function loadDocumentMetadata(documentId: string): DocumentMetadata | null {
try {
const key = `${WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX}${documentId}`;
const json = localStorage.getItem(key);
if (!json) return null;
return JSON.parse(json) as DocumentMetadata;
} catch (error) {
console.error(`Failed to load metadata for ${documentId}:`, error);
return null;
}
}
// Load all document metadata (for workspace initialization)
export function loadAllDocumentMetadata(): Map<string, DocumentMetadata> {
const metadataMap = new Map<string, DocumentMetadata>();
try {
// Iterate through localStorage to find all metadata entries
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX)) {
const documentId = key.replace(WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX, '');
const metadata = loadDocumentMetadata(documentId);
if (metadata) {
metadataMap.set(documentId, metadata);
}
}
}
} catch (error) {
console.error('Failed to load document metadata:', error);
}
return metadataMap;
}
// Check if legacy data exists (for migration)
export function hasLegacyData(): boolean {
return localStorage.getItem(WORKSPACE_STORAGE_KEYS.LEGACY_GRAPH_STATE) !== null;
}
// Clear all workspace data (for reset)
export function clearWorkspaceStorage(): void {
// Remove workspace state
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_STATE);
localStorage.removeItem(WORKSPACE_STORAGE_KEYS.WORKSPACE_SETTINGS);
// Remove all documents and metadata
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_PREFIX) ||
key.startsWith(WORKSPACE_STORAGE_KEYS.DOCUMENT_METADATA_PREFIX)
)) {
keysToRemove.push(key);
}
}
keysToRemove.forEach(key => localStorage.removeItem(key));
}

View file

@ -0,0 +1,93 @@
import type { ConstellationDocument } from '../persistence/types';
import type { NodeTypeConfig, EdgeTypeConfig } from '../../types';
/**
* Workspace Types
*
* Type definitions for multi-document workspace management
*/
// Document metadata (lightweight, for quick loading)
export interface DocumentMetadata {
id: string;
title: string;
isDirty: boolean;
lastModified: string; // ISO timestamp
fileName?: string; // If loaded from file
color?: string; // Tab color identifier
viewport?: { // React Flow viewport state
x: number;
y: number;
zoom: number;
};
}
// Recent file entry
export interface RecentFile {
path: string;
title: string;
lastOpened: string;
thumbnail?: string;
}
// Workspace settings
export interface WorkspaceSettings {
maxOpenDocuments: number;
autoSaveEnabled: boolean;
defaultNodeTypes: NodeTypeConfig[];
defaultEdgeTypes: EdgeTypeConfig[];
recentFiles: RecentFile[];
}
// Workspace state (what gets saved to localStorage)
export interface WorkspaceState {
workspaceId: string;
workspaceName: string;
documentOrder: string[]; // Order of tabs
activeDocumentId: string | null; // Currently visible document
settings: WorkspaceSettings;
}
// Full workspace (in-memory state)
export interface Workspace extends WorkspaceState {
documents: Map<string, ConstellationDocument>; // Loaded documents
documentMetadata: Map<string, DocumentMetadata>; // All document metadata
}
// Workspace actions
export interface WorkspaceActions {
// Document management
createDocument: (title?: string) => string;
createDocumentFromTemplate: (sourceDocumentId: string, title?: string) => string;
loadDocument: (documentId: string) => Promise<void>;
unloadDocument: (documentId: string) => void;
closeDocument: (documentId: string) => boolean;
deleteDocument: (documentId: string) => boolean;
renameDocument: (documentId: string, newTitle: string) => void;
duplicateDocument: (documentId: string) => string;
// Navigation
switchToDocument: (documentId: string) => void;
reorderDocuments: (newOrder: string[]) => void;
// File operations
importDocumentFromFile: () => Promise<string | null>;
exportDocument: (documentId: string) => void;
exportAllDocumentsAsZip: () => Promise<void>;
// Workspace operations
saveWorkspace: () => void;
loadWorkspace: () => void;
clearWorkspace: () => void;
exportWorkspace: () => Promise<void>;
importWorkspace: () => Promise<void>;
// Graph operations (delegates to active document's graph store)
getActiveDocument: () => ConstellationDocument | null;
markDocumentDirty: (documentId: string) => void;
saveDocument: (documentId: string) => void;
// Viewport operations
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => void;
getViewport: (documentId: string) => { x: number; y: number; zoom: number } | undefined;
}

View file

@ -0,0 +1,143 @@
import { useEffect, useRef } from 'react';
import { useWorkspaceStore } from '../workspaceStore';
import { useGraphStore } from '../graphStore';
/**
* useActiveDocument Hook
*
* Synchronizes the graphStore with the active document from workspace
* - Loads active document data into graphStore when document switches
* - Saves graphStore changes back to workspace
* - Manages memory by unloading inactive documents after timeout
*/
const UNLOAD_TIMEOUT = 5 * 60 * 1000; // 5 minutes
export function useActiveDocument() {
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
const activeDocument = useWorkspaceStore((state) => state.getActiveDocument());
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
const saveDocument = useWorkspaceStore((state) => state.saveDocument);
const unloadDocument = useWorkspaceStore((state) => state.unloadDocument);
const documentOrder = useWorkspaceStore((state) => state.documentOrder);
const documents = useWorkspaceStore((state) => state.documents);
const setNodes = useGraphStore((state) => state.setNodes);
const setEdges = useGraphStore((state) => state.setEdges);
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
const graphNodes = useGraphStore((state) => state.nodes);
const graphEdges = useGraphStore((state) => state.edges);
const graphNodeTypes = useGraphStore((state) => state.nodeTypes);
const graphEdgeTypes = useGraphStore((state) => state.edgeTypes);
// Track unload timers for inactive documents
const unloadTimersRef = useRef<Map<string, number>>(new Map());
// Track when we're loading a document to prevent false dirty marking
const isLoadingRef = useRef(false);
const lastLoadedDocIdRef = useRef<string | null>(null);
// Load active document into graphStore when it changes
useEffect(() => {
if (activeDocument && activeDocumentId) {
console.log(`Loading document into graph editor: ${activeDocumentId}`, activeDocument.metadata.title);
// Set loading flag before updating graph state
isLoadingRef.current = true;
lastLoadedDocIdRef.current = activeDocumentId;
setNodes(activeDocument.graph.nodes as never[]);
setEdges(activeDocument.graph.edges as never[]);
setNodeTypes(activeDocument.graph.nodeTypes as never[]);
setEdgeTypes(activeDocument.graph.edgeTypes as never[]);
// Clear loading flag after a brief delay to allow state to settle
setTimeout(() => {
isLoadingRef.current = false;
}, 100);
}
}, [activeDocumentId, activeDocument, documents, setNodes, setEdges, setNodeTypes, setEdgeTypes]);
// Save graphStore changes back to workspace (debounced via workspace)
useEffect(() => {
if (!activeDocumentId || !activeDocument) return;
// Skip dirty checking if we're currently loading a document
if (isLoadingRef.current) {
console.log(`Skipping dirty check - document is loading: ${activeDocumentId}`);
return;
}
// Mark document as dirty when graph changes
const hasChanges =
JSON.stringify(graphNodes) !== JSON.stringify(activeDocument.graph.nodes) ||
JSON.stringify(graphEdges) !== JSON.stringify(activeDocument.graph.edges) ||
JSON.stringify(graphNodeTypes) !== JSON.stringify(activeDocument.graph.nodeTypes) ||
JSON.stringify(graphEdgeTypes) !== JSON.stringify(activeDocument.graph.edgeTypes);
if (hasChanges) {
console.log(`Document ${activeDocumentId} has changes, marking as dirty`);
markDocumentDirty(activeDocumentId);
// Update the document in workspace
activeDocument.graph.nodes = graphNodes as never[];
activeDocument.graph.edges = graphEdges as never[];
activeDocument.graph.nodeTypes = graphNodeTypes as never[];
activeDocument.graph.edgeTypes = graphEdgeTypes as never[];
// Debounced save
const timeoutId = setTimeout(() => {
saveDocument(activeDocumentId);
}, 1000);
return () => clearTimeout(timeoutId);
}
}, [graphNodes, graphEdges, graphNodeTypes, graphEdgeTypes, activeDocumentId, activeDocument, markDocumentDirty, saveDocument]);
// Memory management: Unload inactive documents after timeout
useEffect(() => {
if (!activeDocumentId) return;
const timers = unloadTimersRef.current;
// Clear timer for active document (it should stay loaded)
const activeTimer = timers.get(activeDocumentId);
if (activeTimer) {
clearTimeout(activeTimer);
timers.delete(activeDocumentId);
}
// Set timers for all other loaded documents
documentOrder.forEach((docId) => {
if (docId === activeDocumentId) return; // Skip active
if (!documents.has(docId)) return; // Skip already unloaded
// Clear existing timer for this doc
const existingTimer = timers.get(docId);
if (existingTimer) {
clearTimeout(existingTimer);
}
// Set new timer to unload after timeout
const timer = setTimeout(() => {
console.log(`Unloading inactive document: ${docId}`);
unloadDocument(docId);
timers.delete(docId);
}, UNLOAD_TIMEOUT);
timers.set(docId, timer);
});
// Cleanup all timers on unmount
return () => {
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
};
}, [activeDocumentId, documentOrder, documents, unloadDocument]);
return {
activeDocumentId,
activeDocument,
};
}

View file

@ -0,0 +1,170 @@
import JSZip from 'jszip';
import type { ConstellationDocument } from '../persistence/types';
import type { WorkspaceState } from './types';
/**
* Workspace Import/Export
*
* Functions for exporting and importing entire workspaces
* - Export all documents as ZIP
* - Export workspace state
* - Import workspace from ZIP
*/
/**
* Export all documents as a ZIP file
*/
export async function exportAllDocumentsAsZip(
documents: Map<string, ConstellationDocument>,
workspaceName: string
): Promise<void> {
const zip = new JSZip();
// Add each document as a JSON file
documents.forEach((doc, docId) => {
const filename = `${doc.metadata.title || docId}.json`;
zip.file(filename, JSON.stringify(doc, null, 2));
});
// Generate ZIP and trigger download
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${workspaceName}-documents.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Export complete workspace (state + all documents) as ZIP
*/
export async function exportWorkspace(
workspaceState: WorkspaceState,
documents: Map<string, ConstellationDocument>,
allDocumentIds: string[],
loadDocument: (id: string) => Promise<ConstellationDocument | null>
): Promise<void> {
const zip = new JSZip();
// Add workspace state
zip.file('workspace.json', JSON.stringify(workspaceState, null, 2));
// Add all documents (load them if needed)
const documentsFolder = zip.folder('documents');
if (!documentsFolder) return;
for (const docId of allDocumentIds) {
let doc = documents.get(docId);
// Load document if not in memory
if (!doc) {
const loadedDoc = await loadDocument(docId);
if (loadedDoc) {
doc = loadedDoc;
}
}
if (doc) {
const filename = `${docId}.json`;
documentsFolder.file(filename, JSON.stringify(doc, null, 2));
}
}
// Generate ZIP and trigger download
const blob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${workspaceState.workspaceName}-workspace.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
/**
* Import workspace from ZIP file
*/
export async function importWorkspaceFromZip(
file: File
): Promise<{
workspaceState: WorkspaceState;
documents: Map<string, ConstellationDocument>;
} | null> {
try {
const zip = await JSZip.loadAsync(file);
// Load workspace state
const workspaceFile = zip.file('workspace.json');
if (!workspaceFile) {
throw new Error('Invalid workspace file: workspace.json not found');
}
const workspaceStateText = await workspaceFile.async('text');
const workspaceState: WorkspaceState = JSON.parse(workspaceStateText);
// Load all documents
const documents = new Map<string, ConstellationDocument>();
const documentsFolder = zip.folder('documents');
if (documentsFolder) {
const documentFiles = Object.keys(zip.files).filter(name =>
name.startsWith('documents/') && name.endsWith('.json')
);
for (const filename of documentFiles) {
const file = zip.file(filename);
if (file) {
const content = await file.async('text');
const doc: ConstellationDocument = JSON.parse(content);
if (doc.metadata?.documentId) {
documents.set(doc.metadata.documentId, doc);
}
}
}
}
return { workspaceState, documents };
} catch (error) {
console.error('Failed to import workspace:', error);
return null;
}
}
/**
* Select and import workspace ZIP file
*/
export function selectWorkspaceZipForImport(
onSuccess: (data: {
workspaceState: WorkspaceState;
documents: Map<string, ConstellationDocument>;
}) => void,
onError: (error: string) => void
): void {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip';
input.onchange = async (e) => {
const target = e.target as HTMLInputElement;
const file = target?.files?.[0];
if (!file) {
onError('No file selected');
return;
}
const result = await importWorkspaceFromZip(file);
if (result) {
onSuccess(result);
} else {
onError('Failed to parse workspace ZIP file');
}
};
input.click();
}

View file

@ -0,0 +1,785 @@
import { create } from 'zustand';
import type { ConstellationDocument } from './persistence/types';
import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types';
import { createDocument as createDocumentHelper } from './persistence/saver';
import { selectFileForImport, exportGraphToFile } from './persistence/fileIO';
import {
generateWorkspaceId,
generateDocumentId,
saveWorkspaceState,
loadWorkspaceState,
saveDocumentToStorage,
loadDocumentFromStorage,
deleteDocumentFromStorage,
saveDocumentMetadata,
loadDocumentMetadata,
loadAllDocumentMetadata,
clearWorkspaceStorage,
} from './workspace/persistence';
import { migrateToWorkspace, needsMigration } from './workspace/migration';
import {
exportAllDocumentsAsZip,
exportWorkspace as exportWorkspaceToZip,
selectWorkspaceZipForImport,
} from './workspace/workspaceIO';
/**
* Workspace Store
*
* Manages multiple documents, tabs, and workspace-level settings
*/
// Default workspace settings
const defaultSettings: WorkspaceSettings = {
maxOpenDocuments: 10,
autoSaveEnabled: true,
defaultNodeTypes: [
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' },
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' },
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' },
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' },
],
defaultEdgeTypes: [
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },
{ id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' },
{ id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' },
{ id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' },
],
recentFiles: [],
};
// Initialize workspace
function initializeWorkspace(): Workspace {
// Check if migration is needed
if (needsMigration()) {
console.log('Migration needed, migrating legacy data...');
const migratedState = migrateToWorkspace();
if (migratedState) {
// Load migrated document
const docId = migratedState.activeDocumentId!;
const doc = loadDocumentFromStorage(docId);
const meta = loadDocumentMetadata(docId);
return {
...migratedState,
documents: doc ? new Map([[docId, doc]]) : new Map(),
documentMetadata: meta ? new Map([[docId, meta]]) : new Map(),
};
}
}
// Try to load existing workspace
const savedState = loadWorkspaceState();
if (savedState) {
// Load all document metadata
const metadata = loadAllDocumentMetadata();
// Load active document if exists
const documents = new Map<string, ConstellationDocument>();
if (savedState.activeDocumentId) {
const doc = loadDocumentFromStorage(savedState.activeDocumentId);
if (doc) {
documents.set(savedState.activeDocumentId, doc);
}
}
return {
...savedState,
documents,
documentMetadata: metadata,
};
}
// Create new workspace with no documents (start with empty state)
const workspaceId = generateWorkspaceId();
// Save initial state
const initialState = {
workspaceId,
workspaceName: 'My Workspace',
documentOrder: [],
activeDocumentId: null,
settings: defaultSettings,
};
saveWorkspaceState(initialState);
return {
...initialState,
documents: new Map(),
documentMetadata: new Map(),
};
}
export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get) => ({
...initializeWorkspace(),
// Create new document
createDocument: (title = 'Untitled Analysis') => {
const state = get();
const documentId = generateDocumentId();
const now = new Date().toISOString();
// Create copies of the default types using spread to avoid any circular references from store
const nodeTypes = state.settings.defaultNodeTypes.map(nt => ({ ...nt }));
const edgeTypes = state.settings.defaultEdgeTypes.map(et => ({ ...et }));
const newDoc = createDocumentHelper(
[],
[],
nodeTypes,
edgeTypes
);
newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title;
const metadata: DocumentMetadata = {
id: documentId,
title,
isDirty: false,
lastModified: now,
};
// Save document
saveDocumentToStorage(documentId, newDoc);
saveDocumentMetadata(documentId, metadata);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, newDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
const newState = {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return newState;
});
return documentId;
},
// Create new document from existing document's types (template)
createDocumentFromTemplate: (sourceDocumentId: string, title = 'Untitled Analysis') => {
const state = get();
const sourceDoc = state.documents.get(sourceDocumentId);
if (!sourceDoc) {
console.error(`Source document ${sourceDocumentId} not found`);
return '';
}
const documentId = generateDocumentId();
const now = new Date().toISOString();
// Create new document with the same node and edge types, but no actors/relations
const newDoc = createDocumentHelper(
[],
[],
sourceDoc.graph.nodeTypes,
sourceDoc.graph.edgeTypes
);
newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title;
const metadata: DocumentMetadata = {
id: documentId,
title,
isDirty: false,
lastModified: now,
};
// Save document
saveDocumentToStorage(documentId, newDoc);
saveDocumentMetadata(documentId, metadata);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, newDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
const newState = {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return newState;
});
return documentId;
},
// Load document from storage (if not already loaded)
loadDocument: async (documentId: string) => {
const state = get();
// Check if already loaded
if (state.documents.has(documentId)) {
return;
}
// Load from storage
const doc = loadDocumentFromStorage(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, doc);
return { documents: newDocuments };
});
},
// Unload document from memory (but keep in storage and tab list)
unloadDocument: (documentId: string) => {
const state = get();
// Don't unload if it's the active document
if (documentId === state.activeDocumentId) {
console.warn('Cannot unload active document');
return;
}
// Don't unload if document has unsaved changes
const metadata = state.documentMetadata.get(documentId);
if (metadata?.isDirty) {
console.warn(`Cannot unload document with unsaved changes: ${documentId}`);
return;
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
return { documents: newDocuments };
});
},
// Close document (unload from memory, but keep in storage)
closeDocument: (documentId: string) => {
const state = get();
// Check for unsaved changes
const metadata = state.documentMetadata.get(documentId);
if (metadata?.isDirty) {
const confirmed = window.confirm(
`"${metadata.title}" has unsaved changes. Close anyway?`
);
if (!confirmed) return false;
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
const newOrder = state.documentOrder.filter(id => id !== documentId);
const newActiveId = state.activeDocumentId === documentId
? (newOrder[0] || null)
: state.activeDocumentId;
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: newActiveId,
settings: state.settings,
});
return {
documents: newDocuments,
documentOrder: newOrder,
activeDocumentId: newActiveId,
};
});
return true;
},
// Delete document (remove from storage)
deleteDocument: (documentId: string) => {
const state = get();
const metadata = state.documentMetadata.get(documentId);
const confirmed = window.confirm(
`Are you sure you want to delete "${metadata?.title}"? This cannot be undone.`
);
if (!confirmed) return false;
// Delete from storage
deleteDocumentFromStorage(documentId);
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
const newMetadata = new Map(state.documentMetadata);
newMetadata.delete(documentId);
const newOrder = state.documentOrder.filter(id => id !== documentId);
const newActiveId = state.activeDocumentId === documentId
? (newOrder[0] || null)
: state.activeDocumentId;
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: newActiveId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: newActiveId,
};
});
return true;
},
// Rename document
renameDocument: (documentId: string, newTitle: string) => {
set((state) => {
const doc = state.documents.get(documentId);
if (doc) {
doc.metadata.title = newTitle;
saveDocumentToStorage(documentId, doc);
}
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.title = newTitle;
metadata.lastModified = new Date().toISOString();
saveDocumentMetadata(documentId, metadata);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
},
// Duplicate document
duplicateDocument: (documentId: string) => {
const state = get();
const sourceDoc = state.documents.get(documentId);
if (!sourceDoc) {
console.error(`Document ${documentId} not found`);
return '';
}
const newDocumentId = generateDocumentId();
const sourceMeta = state.documentMetadata.get(documentId);
const newTitle = `${sourceMeta?.title || 'Untitled'} (Copy)`;
const duplicatedDoc: ConstellationDocument = {
...sourceDoc,
metadata: {
...sourceDoc.metadata,
documentId: newDocumentId,
title: newTitle,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
const metadata: DocumentMetadata = {
id: newDocumentId,
title: newTitle,
isDirty: false,
lastModified: new Date().toISOString(),
};
// Save
saveDocumentToStorage(newDocumentId, duplicatedDoc);
saveDocumentMetadata(newDocumentId, metadata);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(newDocumentId, duplicatedDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(newDocumentId, metadata);
const newOrder = [...state.documentOrder, newDocumentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
};
});
return newDocumentId;
},
// Switch active document (opens it as a tab if not already open)
switchToDocument: (documentId: string) => {
get().loadDocument(documentId).then(() => {
set((state) => {
// Add to documentOrder if not already there (reopen closed document)
const newOrder = state.documentOrder.includes(documentId)
? state.documentOrder
: [...state.documentOrder, documentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return {
documentOrder: newOrder,
activeDocumentId: documentId,
};
});
});
},
// Reorder documents
reorderDocuments: (newOrder: string[]) => {
set((state) => {
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
return { documentOrder: newOrder };
});
},
// Import document from file
importDocumentFromFile: async () => {
return new Promise((resolve) => {
selectFileForImport(
(data) => {
const documentId = generateDocumentId();
const now = new Date().toISOString();
const importedDoc = createDocumentHelper(
data.nodes,
data.edges,
data.nodeTypes,
data.edgeTypes
);
importedDoc.metadata.documentId = documentId;
importedDoc.metadata.title = 'Imported Analysis';
const metadata: DocumentMetadata = {
id: documentId,
title: 'Imported Analysis',
isDirty: false,
lastModified: now,
};
saveDocumentToStorage(documentId, importedDoc);
saveDocumentMetadata(documentId, metadata);
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, importedDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
});
resolve(documentId);
},
(error) => {
alert(`Failed to import file: ${error}`);
resolve(null);
}
);
});
},
// Export document to file
exportDocument: (documentId: string) => {
const doc = get().documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
exportGraphToFile(
doc.graph.nodes,
doc.graph.edges,
doc.graph.nodeTypes,
doc.graph.edgeTypes
);
},
// Save workspace
saveWorkspace: () => {
const state = get();
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: state.documentOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
},
// Load workspace
loadWorkspace: () => {
const loadedState = loadWorkspaceState();
if (loadedState) {
const metadata = loadAllDocumentMetadata();
set({
...loadedState,
documentMetadata: metadata,
documents: new Map(), // Documents loaded on demand
});
}
},
// Clear workspace
clearWorkspace: () => {
const confirmed = window.confirm(
'Are you sure you want to clear the entire workspace? This will delete all documents and cannot be undone.'
);
if (!confirmed) return;
clearWorkspaceStorage();
// Re-initialize with fresh workspace
const newState = initializeWorkspace();
set(newState);
},
// Get active document
getActiveDocument: () => {
const state = get();
if (!state.activeDocumentId) return null;
return state.documents.get(state.activeDocumentId) || null;
},
// Mark document as dirty
markDocumentDirty: (documentId: string) => {
set((state) => {
const metadata = state.documentMetadata.get(documentId);
if (metadata && !metadata.isDirty) {
metadata.isDirty = true;
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
saveDocumentMetadata(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
},
// Save document
saveDocument: (documentId: string) => {
const state = get();
const doc = state.documents.get(documentId);
if (doc) {
doc.metadata.updatedAt = new Date().toISOString();
saveDocumentToStorage(documentId, doc);
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.isDirty = false;
metadata.lastModified = doc.metadata.updatedAt;
saveDocumentMetadata(documentId, metadata);
set((state) => {
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
return { documentMetadata: newMetadata };
});
}
}
},
// Export all documents as ZIP
exportAllDocumentsAsZip: async () => {
const state = get();
// Ensure all documents are loaded
const allDocs = new Map<string, ConstellationDocument>();
for (const docId of state.documentOrder) {
let doc = state.documents.get(docId);
if (!doc) {
const loadedDoc = loadDocumentFromStorage(docId);
if (loadedDoc) {
doc = loadedDoc;
}
}
if (doc) {
allDocs.set(docId, doc);
}
}
await exportAllDocumentsAsZip(allDocs, state.workspaceName);
},
// Export workspace
exportWorkspace: async () => {
const state = get();
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
return loadDocumentFromStorage(id);
};
await exportWorkspaceToZip(
{
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: state.documentOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
},
state.documents,
state.documentOrder,
loadDoc
);
},
// Import workspace
importWorkspace: async () => {
return new Promise((resolve) => {
selectWorkspaceZipForImport(
(data) => {
const { workspaceState, documents } = data;
// Save workspace state
saveWorkspaceState(workspaceState);
// Save all documents
documents.forEach((doc, docId) => {
saveDocumentToStorage(docId, doc);
const metadata = {
id: docId,
title: doc.metadata.title || 'Untitled',
isDirty: false,
lastModified: doc.metadata.updatedAt || new Date().toISOString(),
};
saveDocumentMetadata(docId, metadata);
});
// Load metadata for all documents
const allMetadata = loadAllDocumentMetadata();
// Load active document
const activeDoc = workspaceState.activeDocumentId
? documents.get(workspaceState.activeDocumentId)
: null;
set({
...workspaceState,
documents: activeDoc && workspaceState.activeDocumentId
? new Map([[workspaceState.activeDocumentId, activeDoc]])
: new Map(),
documentMetadata: allMetadata,
});
alert('Workspace imported successfully!');
resolve();
},
(error) => {
alert(`Failed to import workspace: ${error}`);
resolve();
}
);
});
},
// Save viewport state for a document
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => {
set((state) => {
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.viewport = viewport;
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
saveDocumentMetadata(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
},
// Get viewport state for a document
getViewport: (documentId: string) => {
const state = get();
const metadata = state.documentMetadata.get(documentId);
return metadata?.viewport;
},
}));

50
src/styles/index.css Normal file
View file

@ -0,0 +1,50 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* React Flow customizations */
.react-flow__node {
cursor: grab;
}
.react-flow__node:active {
cursor: grabbing;
}
.react-flow__handle {
cursor: crosshair;
}
.react-flow__edge-path {
cursor: pointer;
}
/* Remove default outline on React Flow elements */
.react-flow__node.selected {
outline: none;
}
/* Smooth transitions for interactive elements */
button {
transition: all 0.2s ease-in-out;
}

84
src/types/index.ts Normal file
View file

@ -0,0 +1,84 @@
import { Node, Edge } from 'reactflow';
// Node/Actor Types
export interface ActorData {
label: string;
type: string;
description?: string;
metadata?: Record<string, unknown>;
}
export type Actor = Node<ActorData>;
// Edge/Relation Types
export interface RelationData {
label?: string;
type: string;
strength?: number;
metadata?: Record<string, unknown>;
}
export type Relation = Edge<RelationData>;
// Node Type Configuration
export interface NodeTypeConfig {
id: string;
label: string;
color: string;
icon?: string;
description?: string;
}
// Edge Type Configuration
export interface EdgeTypeConfig {
id: string;
label: string;
color: string;
style?: 'solid' | 'dashed' | 'dotted';
description?: string;
}
// Graph State
export interface GraphState {
nodes: Actor[];
edges: Relation[];
nodeTypes: NodeTypeConfig[];
edgeTypes: EdgeTypeConfig[];
}
// Editor Settings
export interface EditorSettings {
snapToGrid: boolean;
showGrid: boolean;
gridSize: number;
panOnDrag: boolean;
zoomOnScroll: boolean;
}
// Store Actions
export interface GraphActions {
addNode: (node: Actor) => void;
updateNode: (id: string, updates: Partial<Actor>) => void;
deleteNode: (id: string) => void;
addEdge: (edge: Relation) => void;
updateEdge: (id: string, data: Partial<RelationData>) => void;
deleteEdge: (id: string) => void;
addNodeType: (nodeType: NodeTypeConfig) => void;
updateNodeType: (id: string, updates: Partial<Omit<NodeTypeConfig, 'id'>>) => void;
deleteNodeType: (id: string) => void;
addEdgeType: (edgeType: EdgeTypeConfig) => void;
updateEdgeType: (id: string, updates: Partial<Omit<EdgeTypeConfig, 'id'>>) => void;
deleteEdgeType: (id: string) => void;
clearGraph: () => void;
setNodes: (nodes: Actor[]) => void;
setEdges: (edges: Relation[]) => void;
setNodeTypes: (nodeTypes: NodeTypeConfig[]) => void;
setEdgeTypes: (edgeTypes: EdgeTypeConfig[]) => void;
exportToFile: () => void;
importFromFile: (onError?: (error: string) => void) => void;
loadGraphState: (data: { nodes: Actor[]; edges: Relation[]; nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[] }) => void;
}
export interface EditorActions {
updateSettings: (settings: Partial<EditorSettings>) => void;
}

65
src/utils/colorUtils.ts Normal file
View file

@ -0,0 +1,65 @@
/**
* Calculates the relative luminance of a color
* Based on WCAG 2.0 formula
*/
const getLuminance = (r: number, g: number, b: number): number => {
const [rs, gs, bs] = [r, g, b].map((c) => {
const val = c / 255;
return val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
/**
* Parses a hex color string to RGB values
*/
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
};
/**
* Determines whether to use white or black text on a given background color
* Returns 'white' or 'black' based on contrast ratio
*
* @param backgroundColor - Hex color string (e.g., '#3b82f6')
* @returns 'white' or 'black'
*/
export const getContrastColor = (backgroundColor: string): 'white' | 'black' => {
const rgb = hexToRgb(backgroundColor);
if (!rgb) return 'black'; // Fallback for invalid colors
const luminance = getLuminance(rgb.r, rgb.g, rgb.b);
// Use white text for dark backgrounds, black for light backgrounds
// Threshold of 0.5 works well for most cases
return luminance > 0.5 ? 'black' : 'white';
};
/**
* Lightens or darkens a hex color by a percentage
*
* @param color - Hex color string
* @param percent - Positive to lighten, negative to darken (0-100)
*/
export const adjustColorBrightness = (color: string, percent: number): string => {
const rgb = hexToRgb(color);
if (!rgb) return color;
const adjust = (value: number) => {
const adjusted = value + (value * percent) / 100;
return Math.min(255, Math.max(0, Math.round(adjusted)));
};
const r = adjust(rgb.r).toString(16).padStart(2, '0');
const g = adjust(rgb.g).toString(16).padStart(2, '0');
const b = adjust(rgb.b).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
};

36
src/utils/edgeUtils.ts Normal file
View file

@ -0,0 +1,36 @@
import type { Relation, RelationData } from '../types';
/**
* Generates a unique ID for edges
*/
export const generateEdgeId = (source: string, target: string): string => {
return `edge_${source}_${target}_${Date.now()}`;
};
/**
* Creates a new relation/edge with default properties
*/
export const createEdge = (
source: string,
target: string,
type: string,
label?: string
): Relation => {
return {
id: generateEdgeId(source, target),
source,
target,
type: 'custom', // Using custom edge component
data: {
label,
type,
},
};
};
/**
* Validates edge data
*/
export const validateEdgeData = (data: RelationData): boolean => {
return !!data.type;
};

55
src/utils/iconUtils.tsx Normal file
View file

@ -0,0 +1,55 @@
import PersonIcon from '@mui/icons-material/Person';
import GroupIcon from '@mui/icons-material/Group';
import BusinessIcon from '@mui/icons-material/Business';
import ComputerIcon from '@mui/icons-material/Computer';
import CloudIcon from '@mui/icons-material/Cloud';
import StorageIcon from '@mui/icons-material/Storage';
import DevicesIcon from '@mui/icons-material/Devices';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import CategoryIcon from '@mui/icons-material/Category';
import LightbulbIcon from '@mui/icons-material/Lightbulb';
import WorkIcon from '@mui/icons-material/Work';
import SchoolIcon from '@mui/icons-material/School';
import LocalHospitalIcon from '@mui/icons-material/LocalHospital';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import StoreIcon from '@mui/icons-material/Store';
import FactoryIcon from '@mui/icons-material/Factory';
import EngineeringIcon from '@mui/icons-material/Engineering';
import ScienceIcon from '@mui/icons-material/Science';
import PublicIcon from '@mui/icons-material/Public';
import LocationCityIcon from '@mui/icons-material/LocationCity';
/**
* Icon map for Material Design icons
* Used to get icon component by name
*/
const iconMap: Record<string, React.ComponentType<{ fontSize?: 'small' | 'medium' | 'large'; className?: string }>> = {
Person: PersonIcon,
Group: GroupIcon,
Business: BusinessIcon,
Computer: ComputerIcon,
Cloud: CloudIcon,
Storage: StorageIcon,
Devices: DevicesIcon,
AccountTree: AccountTreeIcon,
Category: CategoryIcon,
Lightbulb: LightbulbIcon,
Work: WorkIcon,
School: SchoolIcon,
LocalHospital: LocalHospitalIcon,
AccountBalance: AccountBalanceIcon,
Store: StoreIcon,
Factory: FactoryIcon,
Engineering: EngineeringIcon,
Science: ScienceIcon,
Public: PublicIcon,
LocationCity: LocationCityIcon,
};
/**
* Get icon component by name
*/
export const getIconComponent = (iconName?: string): React.ComponentType<{ fontSize?: 'small' | 'medium' | 'large'; className?: string }> | null => {
if (!iconName) return null;
return iconMap[iconName] || null;
};

43
src/utils/nodeUtils.ts Normal file
View file

@ -0,0 +1,43 @@
import type { Actor, ActorData, NodeTypeConfig } from '../types';
/**
* Generates a unique ID for nodes
*/
export const generateNodeId = (): string => {
return `node_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Creates a new actor/node with default properties
*
* @param typeId - The node type ID
* @param position - The position {x, y} to place the node
* @param nodeTypeConfig - Optional node type config to get proper label
* @param customLabel - Optional custom label to override default
*/
export const createNode = (
typeId: string,
position: { x: number; y: number },
nodeTypeConfig?: NodeTypeConfig,
customLabel?: string
): Actor => {
// Determine the label: custom > config label > fallback
const label = customLabel || nodeTypeConfig?.label || `New ${typeId}`;
return {
id: generateNodeId(),
type: 'custom', // Using custom node component
position,
data: {
label,
type: typeId,
},
};
};
/**
* Validates node data
*/
export const validateNodeData = (data: ActorData): boolean => {
return !!(data.label && data.type);
};

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

26
tailwind.config.js Normal file
View file

@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
200: '#bae6fd',
300: '#7dd3fc',
400: '#38bdf8',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
}
}
},
},
plugins: [],
}

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
vite.config.ts Normal file
View file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
open: true
}
})