mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
refactor: migrate undo/redo from per-state to per-document level
Refactors the history system to track complete document state instead of individual timeline states, making timeline operations fully undoable. BREAKING CHANGE: History is now per-document instead of per-timeline-state. Existing undo/redo stacks will be cleared on first load with this change. Changes: - historyStore: Track complete document snapshots (timeline + all states + types) - useDocumentHistory: Simplified to work with document-level operations - timelineStore: All timeline operations now record history - createState, switchToState, deleteState, updateState, duplicateState - Fixed redo button bug (was mutating Zustand state directly) New capabilities: - Undo/redo timeline state creation - Undo/redo timeline state deletion - Undo/redo switching between timeline states - Undo/redo renaming timeline states - Unified history for all document operations Technical improvements: - Proper Zustand state management (no direct mutations) - Document snapshots include entire timeline structure - History methods accept currentSnapshot parameter - Removed TypeScript 'any' types for better type safety 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
227b61b2a0
commit
d775cb8863
5 changed files with 411 additions and 208 deletions
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
## Overview
|
## 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.
|
The Constellation Analyzer features a comprehensive **document-level** undo/redo system that allows users to safely experiment with their graphs and timeline states without fear of permanent mistakes.
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
- ✅ **Per-Document History**: Each document maintains its own independent undo/redo stack (max 50 actions)
|
- ✅ **Document-Level History**: Each document maintains a single unified undo/redo stack (max 50 actions)
|
||||||
|
- ✅ **Complete State Tracking**: Captures entire document state (timeline + all states + types)
|
||||||
|
- ✅ **Timeline Operations**: Undo/redo create state, delete state, switch state, rename state
|
||||||
|
- ✅ **Graph Operations**: Undo/redo node/edge add/delete/move operations
|
||||||
|
- ✅ **Type Configuration**: Undo/redo changes to node/edge types
|
||||||
- ✅ **Keyboard Shortcuts**: Ctrl+Z (undo), Ctrl+Y or Ctrl+Shift+Z (redo)
|
- ✅ **Keyboard Shortcuts**: Ctrl+Z (undo), Ctrl+Y or Ctrl+Shift+Z (redo)
|
||||||
- ✅ **Visual UI**: Undo/Redo buttons in toolbar with disabled states and tooltips
|
- ✅ **Visual UI**: Undo/Redo buttons in toolbar with disabled states and tooltips
|
||||||
- ✅ **Action Descriptions**: Hover tooltips show what action will be undone/redone
|
- ✅ **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
|
- ✅ **Debounced Moves**: Node dragging is debounced to avoid cluttering history
|
||||||
- ✅ **Document Switching**: History is preserved when switching between documents
|
- ✅ **Document Switching**: History is preserved when switching between documents
|
||||||
|
|
||||||
|
|
@ -17,7 +20,7 @@ The Constellation Analyzer now features a comprehensive per-document undo/redo s
|
||||||
|
|
||||||
### 1. History Store (`src/stores/historyStore.ts`)
|
### 1. History Store (`src/stores/historyStore.ts`)
|
||||||
|
|
||||||
The central store manages history for all documents:
|
The central store manages history for all documents with **complete document snapshots**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
|
|
@ -27,16 +30,28 @@ The central store manages history for all documents:
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `DocumentHistory` contains:
|
Each `DocumentHistory` contains:
|
||||||
- `undoStack`: Array of past actions (most recent at end)
|
- `undoStack`: Array of past document states (most recent at end)
|
||||||
- `redoStack`: Array of undone actions that can be redone
|
- `redoStack`: Array of undone document states that can be redone
|
||||||
- `currentState`: The current document state snapshot
|
|
||||||
|
Each `DocumentSnapshot` contains:
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
timeline: {
|
||||||
|
states: Map<StateId, ConstellationState> // ALL timeline states
|
||||||
|
currentStateId: StateId // Which state is active
|
||||||
|
rootStateId: StateId // Root state ID
|
||||||
|
}
|
||||||
|
nodeTypes: NodeTypeConfig[] // Global node types
|
||||||
|
edgeTypes: EdgeTypeConfig[] // Global edge types
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `pushAction(documentId, action)`: Records a new action
|
- `pushAction(documentId, action)`: Records complete document snapshot
|
||||||
- `undo(documentId)`: Reverts to previous state
|
- `undo(documentId)`: Reverts to previous document state
|
||||||
- `redo(documentId)`: Restores undone state
|
- `redo(documentId)`: Restores undone document state
|
||||||
- `canUndo/canRedo(documentId)`: Check if actions available
|
- `canUndo/canRedo(documentId)`: Check if actions available
|
||||||
- `initializeHistory(documentId, initialState)`: Setup history for new document
|
- `initializeHistory(documentId)`: Setup history for new document
|
||||||
- `removeHistory(documentId)`: Clean up when document deleted
|
- `removeHistory(documentId)`: Clean up when document deleted
|
||||||
|
|
||||||
### 2. Document History Hook (`src/hooks/useDocumentHistory.ts`)
|
### 2. Document History Hook (`src/hooks/useDocumentHistory.ts`)
|
||||||
|
|
@ -49,10 +64,14 @@ const { undo, redo, canUndo, canRedo, undoDescription, redoDescription, pushToHi
|
||||||
|
|
||||||
**Responsibilities:**
|
**Responsibilities:**
|
||||||
- Initializes history when document is first loaded
|
- Initializes history when document is first loaded
|
||||||
- Provides `pushToHistory(description)` to record actions
|
- Provides `pushToHistory(description)` to record complete document snapshots
|
||||||
- Handles undo/redo by restoring document state
|
- Handles undo/redo by restoring:
|
||||||
- Updates both graphStore and workspaceStore on undo/redo
|
- Complete timeline structure (all states)
|
||||||
|
- Current timeline state
|
||||||
|
- Global node/edge types
|
||||||
|
- Current state's graph (nodes and edges)
|
||||||
- Marks documents as dirty after undo/redo
|
- Marks documents as dirty after undo/redo
|
||||||
|
- Triggers auto-save after changes
|
||||||
|
|
||||||
### 3. Graph Operations with History (`src/hooks/useGraphWithHistory.ts`)
|
### 3. Graph Operations with History (`src/hooks/useGraphWithHistory.ts`)
|
||||||
|
|
||||||
|
|
@ -151,46 +170,101 @@ The current codebase uses **Option A** (manual tracking). Components like `Graph
|
||||||
|
|
||||||
To enable automatic tracking, update components to use `useGraphWithHistory()` instead of `useGraphStore()`.
|
To enable automatic tracking, update components to use `useGraphWithHistory()` instead of `useGraphStore()`.
|
||||||
|
|
||||||
|
### 4. Timeline Operations with History (`src/stores/timelineStore.ts`)
|
||||||
|
|
||||||
|
**All timeline operations automatically record history:**
|
||||||
|
|
||||||
|
Tracked operations:
|
||||||
|
- `createState(label)`: Creates new timeline state → "Create State: Feature A"
|
||||||
|
- `switchToState(stateId)`: Switches to different state → "Switch to State: Initial State"
|
||||||
|
- `deleteState(stateId)`: Deletes timeline state → "Delete State: Old Design"
|
||||||
|
- `updateState(stateId, updates)`: Renames or updates state → "Rename State: Draft → Final"
|
||||||
|
- `duplicateState(stateId)`: Duplicates state → "Duplicate State: Copy"
|
||||||
|
- `duplicateStateAsChild(stateId)`: Duplicates as child → "Duplicate State as Child: Version 2"
|
||||||
|
|
||||||
|
Each operation calls `pushDocumentHistory()` helper before making changes.
|
||||||
|
|
||||||
## How It Works: Undo/Redo Flow
|
## How It Works: Undo/Redo Flow
|
||||||
|
|
||||||
### Recording an Action
|
### Recording an Action
|
||||||
|
|
||||||
1. User performs action (e.g., adds a node)
|
1. User performs action (e.g., "adds a node" or "creates a timeline state")
|
||||||
2. `pushToHistory('Add Person Actor')` is called
|
2. `pushToHistory('Add Person Actor')` or `pushDocumentHistory('Create State: Feature A')` is called
|
||||||
3. Current document state is snapshotted
|
3. **Complete document state** is snapshotted:
|
||||||
|
- Entire timeline (all states)
|
||||||
|
- Current state ID
|
||||||
|
- Global node/edge types
|
||||||
4. Snapshot is pushed to `undoStack`
|
4. Snapshot is pushed to `undoStack`
|
||||||
5. `redoStack` is cleared (since new action invalidates redo)
|
5. `redoStack` is cleared (since new action invalidates redo)
|
||||||
|
|
||||||
### Performing Undo
|
### Performing Undo
|
||||||
|
|
||||||
1. User presses Ctrl+Z or clicks Undo button
|
1. User presses Ctrl+Z or clicks Undo button
|
||||||
2. Last action is popped from `undoStack`
|
2. Last document snapshot is popped from `undoStack`
|
||||||
3. Current state is pushed to `redoStack`
|
3. Current document state is pushed to `redoStack`
|
||||||
4. Previous state from action is restored
|
4. Previous document state is restored:
|
||||||
5. GraphStore and WorkspaceStore are updated
|
- Timeline structure loaded into timelineStore
|
||||||
6. Document marked as dirty
|
- Types loaded into graphStore
|
||||||
|
- Current state's graph loaded into graphStore
|
||||||
|
5. Document marked as dirty and auto-saved
|
||||||
|
|
||||||
### Performing Redo
|
### Performing Redo
|
||||||
|
|
||||||
1. User presses Ctrl+Y or clicks Redo button
|
1. User presses Ctrl+Y or clicks Redo button
|
||||||
2. Last undone action is popped from `redoStack`
|
2. Last undone snapshot is popped from `redoStack`
|
||||||
3. Current state is pushed to `undoStack`
|
3. Current document state is pushed to `undoStack`
|
||||||
4. Future state from undone action is restored
|
4. Future document state is restored (same process as undo)
|
||||||
5. GraphStore and WorkspaceStore are updated
|
5. Document marked as dirty and auto-saved
|
||||||
6. Document marked as dirty
|
|
||||||
|
|
||||||
## Per-Document Independence
|
## Per-Document Independence
|
||||||
|
|
||||||
**Critical Feature:** Each document has completely separate history.
|
**Critical Feature:** Each document has completely separate history.
|
||||||
|
|
||||||
Example workflow:
|
Example workflow:
|
||||||
1. Document A: Add 3 nodes
|
1. Document A: Add 3 nodes, create timeline state "Feature X"
|
||||||
2. Switch to Document B: Add 2 edges
|
2. Switch to Document B: Add 2 edges, switch to "Design 2" state
|
||||||
3. Switch back to Document A: Can still undo those 3 node additions
|
3. Switch back to Document A: Can undo timeline state creation AND node additions
|
||||||
4. Switch back to Document B: Can still undo those 2 edge additions
|
4. Switch back to Document B: Can undo state switch AND edge additions
|
||||||
|
|
||||||
History stacks are **preserved** across document switches and **remain independent**.
|
History stacks are **preserved** across document switches and **remain independent**.
|
||||||
|
|
||||||
|
## What Can Be Undone?
|
||||||
|
|
||||||
|
### Graph Operations (within current state)
|
||||||
|
- Add/delete/move nodes
|
||||||
|
- Add/delete/update edges
|
||||||
|
- Add/delete/update node types
|
||||||
|
- Add/delete/update edge types
|
||||||
|
- Clear graph
|
||||||
|
|
||||||
|
### Timeline Operations (NEW!)
|
||||||
|
- Create new timeline state
|
||||||
|
- Delete timeline state
|
||||||
|
- Switch between timeline states
|
||||||
|
- Rename timeline state
|
||||||
|
- Duplicate timeline state
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Example 1: Undoing Timeline Creation**
|
||||||
|
1. Create state "Feature A" → switches to it
|
||||||
|
2. Add some nodes in "Feature A"
|
||||||
|
3. Press Ctrl+Z → nodes are undone
|
||||||
|
4. Press Ctrl+Z again → "Feature A" state is deleted, returns to previous state
|
||||||
|
|
||||||
|
**Example 2: Undoing State Switch**
|
||||||
|
1. Currently in "Initial State"
|
||||||
|
2. Switch to "Design 2" state
|
||||||
|
3. Press Ctrl+Z → switches back to "Initial State"
|
||||||
|
|
||||||
|
**Example 3: Mixed Operations**
|
||||||
|
1. Add node "Person 1"
|
||||||
|
2. Create state "Scenario A"
|
||||||
|
3. Add node "Person 2" in "Scenario A"
|
||||||
|
4. Press Ctrl+Z → "Person 2" is undone
|
||||||
|
5. Press Ctrl+Z → "Scenario A" state is deleted
|
||||||
|
6. Press Ctrl+Z → "Person 1" is undone
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
### Memory Management
|
### Memory Management
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,16 @@ import { useWorkspaceStore } from '../stores/workspaceStore';
|
||||||
import { useHistoryStore } from '../stores/historyStore';
|
import { useHistoryStore } from '../stores/historyStore';
|
||||||
import { useGraphStore } from '../stores/graphStore';
|
import { useGraphStore } from '../stores/graphStore';
|
||||||
import { useTimelineStore } from '../stores/timelineStore';
|
import { useTimelineStore } from '../stores/timelineStore';
|
||||||
import type { GraphSnapshot } from '../stores/historyStore';
|
import type { DocumentSnapshot } from '../stores/historyStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* useDocumentHistory Hook
|
* useDocumentHistory Hook
|
||||||
*
|
*
|
||||||
* Provides undo/redo functionality for the active timeline state.
|
* Provides undo/redo functionality for the active document.
|
||||||
* Each timeline state has its own independent history stack (max 50 actions).
|
* Each document has its own independent history stack (max 50 actions).
|
||||||
*
|
*
|
||||||
* IMPORTANT: History is per-timeline-state. Each state in a document's timeline has completely separate undo/redo stacks.
|
* IMPORTANT: History is per-document. All operations (graph changes, timeline operations)
|
||||||
|
* are tracked in a single unified history stack for the entire document.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* const { undo, redo, canUndo, canRedo, pushToHistory } = useDocumentHistory();
|
* const { undo, redo, canUndo, canRedo, pushToHistory } = useDocumentHistory();
|
||||||
|
|
@ -20,83 +21,109 @@ export function useDocumentHistory() {
|
||||||
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
|
const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId);
|
||||||
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
|
const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty);
|
||||||
|
|
||||||
const setNodes = useGraphStore((state) => state.setNodes);
|
|
||||||
const setEdges = useGraphStore((state) => state.setEdges);
|
|
||||||
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
|
const setNodeTypes = useGraphStore((state) => state.setNodeTypes);
|
||||||
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
|
const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes);
|
||||||
|
|
||||||
const historyStore = useHistoryStore();
|
const historyStore = useHistoryStore();
|
||||||
|
|
||||||
// Get current timeline state ID
|
// Initialize history for active document
|
||||||
const currentStateId = useTimelineStore((state) => {
|
|
||||||
if (!activeDocumentId) return null;
|
|
||||||
const timeline = state.timelines.get(activeDocumentId);
|
|
||||||
return timeline?.currentStateId || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize history for active timeline state
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentStateId) return;
|
if (!activeDocumentId) return;
|
||||||
|
|
||||||
const history = historyStore.histories.get(currentStateId);
|
const history = historyStore.histories.get(activeDocumentId);
|
||||||
if (!history) {
|
if (!history) {
|
||||||
historyStore.initializeHistory(currentStateId);
|
historyStore.initializeHistory(activeDocumentId);
|
||||||
}
|
}
|
||||||
}, [currentStateId, historyStore]);
|
}, [activeDocumentId, historyStore]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push current graph state to history
|
* Push current document state to history (timeline + types)
|
||||||
*/
|
*/
|
||||||
const pushToHistory = useCallback(
|
const pushToHistory = useCallback(
|
||||||
(description: string) => {
|
(description: string) => {
|
||||||
if (!currentStateId) {
|
if (!activeDocumentId) {
|
||||||
console.warn('No active timeline state to record action');
|
console.warn('No active document to record action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read current state directly from store (not from React hooks which might be stale)
|
// Get current state from stores
|
||||||
const currentState = useGraphStore.getState();
|
const graphStore = useGraphStore.getState();
|
||||||
|
const timelineStore = useTimelineStore.getState();
|
||||||
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
||||||
|
|
||||||
// Create a snapshot of the current graph state
|
if (!timeline) {
|
||||||
const snapshot: GraphSnapshot = {
|
console.warn('No timeline found for active document');
|
||||||
nodes: currentState.nodes,
|
return;
|
||||||
edges: currentState.edges,
|
}
|
||||||
nodeTypes: currentState.nodeTypes,
|
|
||||||
edgeTypes: currentState.edgeTypes,
|
// Create a snapshot of the complete document state
|
||||||
|
const snapshot: DocumentSnapshot = {
|
||||||
|
timeline: {
|
||||||
|
states: new Map(timeline.states), // Clone the Map
|
||||||
|
currentStateId: timeline.currentStateId,
|
||||||
|
rootStateId: timeline.rootStateId,
|
||||||
|
},
|
||||||
|
nodeTypes: graphStore.nodeTypes,
|
||||||
|
edgeTypes: graphStore.edgeTypes,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Push to history
|
// Push to history
|
||||||
historyStore.pushAction(currentStateId, {
|
historyStore.pushAction(activeDocumentId, {
|
||||||
description,
|
description,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
graphState: snapshot,
|
documentState: snapshot,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[currentStateId, historyStore]
|
[activeDocumentId, historyStore]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo the last action for the active timeline state
|
* Undo the last action for the active document
|
||||||
*/
|
*/
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (!currentStateId || !activeDocumentId) {
|
if (!activeDocumentId) {
|
||||||
console.warn('No active timeline state to undo');
|
console.warn('No active document to undo');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoredState = historyStore.undo(currentStateId);
|
// Capture current state BEFORE undoing
|
||||||
if (restoredState) {
|
const graphStore = useGraphStore.getState();
|
||||||
// Update graph store with restored state
|
const timelineStore = useTimelineStore.getState();
|
||||||
setNodes(restoredState.nodes as never[]);
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
||||||
setEdges(restoredState.edges as never[]);
|
|
||||||
setNodeTypes(restoredState.nodeTypes as never[]);
|
|
||||||
setEdgeTypes(restoredState.edgeTypes as never[]);
|
|
||||||
|
|
||||||
// Update the timeline's current state with the restored graph (nodes and edges only)
|
if (!timeline) {
|
||||||
useTimelineStore.getState().saveCurrentGraph({
|
console.warn('No timeline found for active document');
|
||||||
nodes: restoredState.nodes as never[],
|
return;
|
||||||
edges: restoredState.edges as never[],
|
}
|
||||||
|
|
||||||
|
const currentSnapshot: DocumentSnapshot = {
|
||||||
|
timeline: {
|
||||||
|
states: new Map(timeline.states),
|
||||||
|
currentStateId: timeline.currentStateId,
|
||||||
|
rootStateId: timeline.rootStateId,
|
||||||
|
},
|
||||||
|
nodeTypes: graphStore.nodeTypes,
|
||||||
|
edgeTypes: graphStore.edgeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoredState = historyStore.undo(activeDocumentId, currentSnapshot);
|
||||||
|
if (restoredState) {
|
||||||
|
|
||||||
|
// Restore complete document state (timeline + types)
|
||||||
|
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
||||||
|
|
||||||
|
// Update graph store types
|
||||||
|
setNodeTypes(restoredState.nodeTypes);
|
||||||
|
setEdgeTypes(restoredState.edgeTypes);
|
||||||
|
|
||||||
|
// Load the current state's graph from the restored timeline
|
||||||
|
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
||||||
|
if (currentState) {
|
||||||
|
useGraphStore.setState({
|
||||||
|
nodes: currentState.graph.nodes,
|
||||||
|
edges: currentState.graph.edges,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mark document as dirty and trigger auto-save
|
// Mark document as dirty and trigger auto-save
|
||||||
markDocumentDirty(activeDocumentId);
|
markDocumentDirty(activeDocumentId);
|
||||||
|
|
@ -107,30 +134,55 @@ export function useDocumentHistory() {
|
||||||
saveDocument(activeDocumentId);
|
saveDocument(activeDocumentId);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}, [currentStateId, activeDocumentId, historyStore, setNodes, setEdges, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Redo the last undone action for the active timeline state
|
* Redo the last undone action for the active document
|
||||||
*/
|
*/
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback(() => {
|
||||||
if (!currentStateId || !activeDocumentId) {
|
if (!activeDocumentId) {
|
||||||
console.warn('No active timeline state to redo');
|
console.warn('No active document to redo');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const restoredState = historyStore.redo(currentStateId);
|
// Capture current state BEFORE redoing
|
||||||
if (restoredState) {
|
const graphStore = useGraphStore.getState();
|
||||||
// Update graph store with restored state
|
const timelineStore = useTimelineStore.getState();
|
||||||
setNodes(restoredState.nodes as never[]);
|
const timeline = timelineStore.timelines.get(activeDocumentId);
|
||||||
setEdges(restoredState.edges as never[]);
|
|
||||||
setNodeTypes(restoredState.nodeTypes as never[]);
|
|
||||||
setEdgeTypes(restoredState.edgeTypes as never[]);
|
|
||||||
|
|
||||||
// Update the timeline's current state with the restored graph (nodes and edges only)
|
if (!timeline) {
|
||||||
useTimelineStore.getState().saveCurrentGraph({
|
console.warn('No timeline found for active document');
|
||||||
nodes: restoredState.nodes as never[],
|
return;
|
||||||
edges: restoredState.edges as never[],
|
}
|
||||||
|
|
||||||
|
const currentSnapshot: DocumentSnapshot = {
|
||||||
|
timeline: {
|
||||||
|
states: new Map(timeline.states),
|
||||||
|
currentStateId: timeline.currentStateId,
|
||||||
|
rootStateId: timeline.rootStateId,
|
||||||
|
},
|
||||||
|
nodeTypes: graphStore.nodeTypes,
|
||||||
|
edgeTypes: graphStore.edgeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoredState = historyStore.redo(activeDocumentId, currentSnapshot);
|
||||||
|
if (restoredState) {
|
||||||
|
|
||||||
|
// Restore complete document state (timeline + types)
|
||||||
|
timelineStore.loadTimeline(activeDocumentId, restoredState.timeline);
|
||||||
|
|
||||||
|
// Update graph store types
|
||||||
|
setNodeTypes(restoredState.nodeTypes);
|
||||||
|
setEdgeTypes(restoredState.edgeTypes);
|
||||||
|
|
||||||
|
// Load the current state's graph from the restored timeline
|
||||||
|
const currentState = restoredState.timeline.states.get(restoredState.timeline.currentStateId);
|
||||||
|
if (currentState) {
|
||||||
|
useGraphStore.setState({
|
||||||
|
nodes: currentState.graph.nodes,
|
||||||
|
edges: currentState.graph.edges,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mark document as dirty and trigger auto-save
|
// Mark document as dirty and trigger auto-save
|
||||||
markDocumentDirty(activeDocumentId);
|
markDocumentDirty(activeDocumentId);
|
||||||
|
|
@ -141,30 +193,30 @@ export function useDocumentHistory() {
|
||||||
saveDocument(activeDocumentId);
|
saveDocument(activeDocumentId);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
}, [currentStateId, activeDocumentId, historyStore, setNodes, setEdges, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
}, [activeDocumentId, historyStore, setNodeTypes, setEdgeTypes, markDocumentDirty]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if undo is available for the active timeline state
|
* Check if undo is available for the active document
|
||||||
*/
|
*/
|
||||||
const canUndo = currentStateId ? historyStore.canUndo(currentStateId) : false;
|
const canUndo = activeDocumentId ? historyStore.canUndo(activeDocumentId) : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if redo is available for the active timeline state
|
* Check if redo is available for the active document
|
||||||
*/
|
*/
|
||||||
const canRedo = currentStateId ? historyStore.canRedo(currentStateId) : false;
|
const canRedo = activeDocumentId ? historyStore.canRedo(activeDocumentId) : false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the description of the next undo action
|
* Get the description of the next undo action
|
||||||
*/
|
*/
|
||||||
const undoDescription = currentStateId
|
const undoDescription = activeDocumentId
|
||||||
? historyStore.getUndoDescription(currentStateId)
|
? historyStore.getUndoDescription(activeDocumentId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the description of the next redo action
|
* Get the description of the next redo action
|
||||||
*/
|
*/
|
||||||
const redoDescription = currentStateId
|
const redoDescription = activeDocumentId
|
||||||
? historyStore.getRedoDescription(currentStateId)
|
? historyStore.getRedoDescription(activeDocumentId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,14 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr
|
||||||
*
|
*
|
||||||
* ✅ USE THIS HOOK FOR ALL GRAPH MUTATIONS IN COMPONENTS ✅
|
* ✅ USE THIS HOOK FOR ALL GRAPH MUTATIONS IN COMPONENTS ✅
|
||||||
*
|
*
|
||||||
* This hook wraps graph store operations with automatic history tracking.
|
* This hook wraps graph store operations with automatic document-level history tracking.
|
||||||
* Every operation that modifies the graph pushes a snapshot to the history stack,
|
* Every operation that modifies the graph pushes a complete document snapshot to the history stack,
|
||||||
* enabling undo/redo functionality.
|
* enabling undo/redo functionality for both graph changes and timeline operations.
|
||||||
*
|
*
|
||||||
* ⚠️ IMPORTANT: Always use this hook instead of `useGraphStore()` in components
|
* ⚠️ IMPORTANT: Always use this hook instead of `useGraphStore()` in components
|
||||||
* that modify graph state.
|
* that modify graph state.
|
||||||
*
|
*
|
||||||
* History-tracked operations:
|
* History-tracked operations (saved to document-level history):
|
||||||
* - Node operations: addNode, updateNode, deleteNode
|
* - Node operations: addNode, updateNode, deleteNode
|
||||||
* - Edge operations: addEdge, updateEdge, deleteEdge
|
* - Edge operations: addEdge, updateEdge, deleteEdge
|
||||||
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
|
* - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType
|
||||||
|
|
|
||||||
|
|
@ -1,75 +1,84 @@
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { useGraphStore } from "./graphStore";
|
import type { NodeTypeConfig, EdgeTypeConfig } from "../types";
|
||||||
import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from "../types";
|
import type { ConstellationState, StateId } from "../types/timeline";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* History Store - Per-Timeline-State Undo/Redo System
|
* History Store - Per-Document Undo/Redo System
|
||||||
*
|
*
|
||||||
* Each timeline state maintains its own independent history stack with a maximum of 50 actions.
|
* 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.
|
* Tracks ALL reversible operations at the document level:
|
||||||
|
* - Graph operations: node add/delete/move, edge add/delete/edit
|
||||||
|
* - Timeline operations: create state, delete state, switch state, rename state
|
||||||
|
* - Type configuration: add/delete/update node/edge types
|
||||||
*
|
*
|
||||||
* IMPORTANT: History is per-timeline-state. Each state in a document's timeline has completely separate undo/redo stacks.
|
* IMPORTANT: History is per-document. Each document has a single unified undo/redo stack
|
||||||
|
* that captures the complete document state (timeline + types) for each action.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface GraphSnapshot {
|
export interface DocumentSnapshot {
|
||||||
nodes: Actor[];
|
// Complete timeline structure (all states)
|
||||||
edges: Relation[];
|
timeline: {
|
||||||
|
states: Map<StateId, ConstellationState>;
|
||||||
|
currentStateId: StateId;
|
||||||
|
rootStateId: StateId;
|
||||||
|
};
|
||||||
|
// Global types (shared across all timeline states)
|
||||||
nodeTypes: NodeTypeConfig[];
|
nodeTypes: NodeTypeConfig[];
|
||||||
edgeTypes: EdgeTypeConfig[];
|
edgeTypes: EdgeTypeConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoryAction {
|
export interface HistoryAction {
|
||||||
description: string; // Human-readable description (e.g., "Add Person Actor", "Delete Collaborates Relation")
|
description: string; // Human-readable description (e.g., "Add Person Actor", "Create State: Feature A")
|
||||||
timestamp: number; // When the action occurred
|
timestamp: number; // When the action occurred
|
||||||
graphState: GraphSnapshot; // Graph state snapshot (not full document)
|
documentState: DocumentSnapshot; // Complete document state snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StateHistory {
|
export interface DocumentHistory {
|
||||||
undoStack: HistoryAction[]; // Past states to restore (most recent at end)
|
undoStack: HistoryAction[]; // Past states to restore (most recent at end)
|
||||||
redoStack: HistoryAction[]; // Future states to restore (most recent at end)
|
redoStack: HistoryAction[]; // Future states to restore (most recent at end)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryStore {
|
interface HistoryStore {
|
||||||
// Map of stateId -> history (each timeline state has its own independent history)
|
// Map of documentId -> history (each document has its own independent history)
|
||||||
histories: Map<string, StateHistory>;
|
histories: Map<string, DocumentHistory>;
|
||||||
|
|
||||||
// Max number of actions to keep in history per state
|
// Max number of actions to keep in history per document
|
||||||
maxHistorySize: number;
|
maxHistorySize: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryActions {
|
interface HistoryActions {
|
||||||
// Initialize history for a timeline state
|
// Initialize history for a document
|
||||||
initializeHistory: (stateId: string) => void;
|
initializeHistory: (documentId: string) => void;
|
||||||
|
|
||||||
// Push a new action onto the state's history stack
|
// Push a new action onto the document's history stack
|
||||||
pushAction: (stateId: string, action: HistoryAction) => void;
|
pushAction: (documentId: string, action: HistoryAction) => void;
|
||||||
|
|
||||||
// Undo the last action for a specific state
|
// Undo the last action for a specific document
|
||||||
undo: (stateId: string) => GraphSnapshot | null;
|
undo: (documentId: string, currentSnapshot: DocumentSnapshot) => DocumentSnapshot | null;
|
||||||
|
|
||||||
// Redo the last undone action for a specific state
|
// Redo the last undone action for a specific document
|
||||||
redo: (stateId: string) => GraphSnapshot | null;
|
redo: (documentId: string, currentSnapshot: DocumentSnapshot) => DocumentSnapshot | null;
|
||||||
|
|
||||||
// Check if undo is available for a state
|
// Check if undo is available for a document
|
||||||
canUndo: (stateId: string) => boolean;
|
canUndo: (documentId: string) => boolean;
|
||||||
|
|
||||||
// Check if redo is available for a state
|
// Check if redo is available for a document
|
||||||
canRedo: (stateId: string) => boolean;
|
canRedo: (documentId: string) => boolean;
|
||||||
|
|
||||||
// Get the description of the next undo action for a state
|
// Get the description of the next undo action for a document
|
||||||
getUndoDescription: (stateId: string) => string | null;
|
getUndoDescription: (documentId: string) => string | null;
|
||||||
|
|
||||||
// Get the description of the next redo action for a state
|
// Get the description of the next redo action for a document
|
||||||
getRedoDescription: (stateId: string) => string | null;
|
getRedoDescription: (documentId: string) => string | null;
|
||||||
|
|
||||||
// Clear history for a specific state
|
// Clear history for a specific document
|
||||||
clearHistory: (stateId: string) => void;
|
clearHistory: (documentId: string) => void;
|
||||||
|
|
||||||
// Remove history for a state (when state is deleted)
|
// Remove history for a document (when document is deleted)
|
||||||
removeHistory: (stateId: string) => void;
|
removeHistory: (documentId: string) => void;
|
||||||
|
|
||||||
// Get history stats for debugging
|
// Get history stats for debugging
|
||||||
getHistoryStats: (stateId: string) => {
|
getHistoryStats: (documentId: string) => {
|
||||||
undoCount: number;
|
undoCount: number;
|
||||||
redoCount: number;
|
redoCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
@ -82,13 +91,13 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
histories: new Map(),
|
histories: new Map(),
|
||||||
maxHistorySize: MAX_HISTORY_SIZE,
|
maxHistorySize: MAX_HISTORY_SIZE,
|
||||||
|
|
||||||
initializeHistory: (stateId: string) => {
|
initializeHistory: (documentId: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newHistories = new Map(state.histories);
|
const newHistories = new Map(state.histories);
|
||||||
|
|
||||||
// Only initialize if not already present
|
// Only initialize if not already present
|
||||||
if (!newHistories.has(stateId)) {
|
if (!newHistories.has(documentId)) {
|
||||||
newHistories.set(stateId, {
|
newHistories.set(documentId, {
|
||||||
undoStack: [],
|
undoStack: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
});
|
});
|
||||||
|
|
@ -98,30 +107,36 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
pushAction: (stateId: string, action: HistoryAction) => {
|
pushAction: (documentId: string, action: HistoryAction) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newHistories = new Map(state.histories);
|
const newHistories = new Map(state.histories);
|
||||||
const history = newHistories.get(stateId);
|
const history = newHistories.get(documentId);
|
||||||
|
|
||||||
if (!history) {
|
if (!history) {
|
||||||
console.warn(`History not initialized for state ${stateId}`);
|
console.warn(`History not initialized for document ${documentId}`);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📝 pushAction:", {
|
console.log("📝 pushAction:", {
|
||||||
description: action.description,
|
description: action.description,
|
||||||
actionStateNodes: action.graphState.nodes.length,
|
currentStateId: action.documentState.timeline.currentStateId,
|
||||||
actionStateEdges: action.graphState.edges.length,
|
stateCount: action.documentState.timeline.states.size,
|
||||||
currentUndoStackSize: history.undoStack.length,
|
currentUndoStackSize: history.undoStack.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The action.graphState contains the state BEFORE the action was performed
|
// 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
|
// We push this to the undo stack so we can restore it if the user clicks undo
|
||||||
const newUndoStack = [...history.undoStack];
|
const newUndoStack = [...history.undoStack];
|
||||||
newUndoStack.push({
|
newUndoStack.push({
|
||||||
description: action.description,
|
description: action.description,
|
||||||
timestamp: action.timestamp,
|
timestamp: action.timestamp,
|
||||||
graphState: JSON.parse(JSON.stringify(action.graphState)), // Deep copy
|
documentState: JSON.parse(JSON.stringify(action.documentState, (_key, value) => {
|
||||||
|
// Convert Map to object for serialization
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return Object.fromEntries(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})), // Deep copy with Map conversion
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trim undo stack if it exceeds max size
|
// Trim undo stack if it exceeds max size
|
||||||
|
|
@ -132,7 +147,7 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
// Clear redo stack when a new action is performed (can't redo after new action)
|
// Clear redo stack when a new action is performed (can't redo after new action)
|
||||||
const newRedoStack: HistoryAction[] = [];
|
const newRedoStack: HistoryAction[] = [];
|
||||||
|
|
||||||
newHistories.set(stateId, {
|
newHistories.set(documentId, {
|
||||||
undoStack: newUndoStack,
|
undoStack: newUndoStack,
|
||||||
redoStack: newRedoStack,
|
redoStack: newRedoStack,
|
||||||
});
|
});
|
||||||
|
|
@ -140,19 +155,15 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
console.log("📝 after push:", {
|
console.log("📝 after push:", {
|
||||||
description: action.description,
|
description: action.description,
|
||||||
newUndoStackSize: newUndoStack.length,
|
newUndoStackSize: newUndoStack.length,
|
||||||
topOfStackNodes:
|
|
||||||
newUndoStack[newUndoStack.length - 1]?.graphState.nodes.length,
|
|
||||||
topOfStackEdges:
|
|
||||||
newUndoStack[newUndoStack.length - 1]?.graphState.edges.length,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { histories: newHistories };
|
return { histories: newHistories };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
undo: (stateId: string) => {
|
undo: (documentId: string, currentSnapshot: DocumentSnapshot) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
|
|
||||||
if (!history || history.undoStack.length === 0) {
|
if (!history || history.undoStack.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -170,47 +181,52 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
const lastAction = history.undoStack[history.undoStack.length - 1];
|
const lastAction = history.undoStack[history.undoStack.length - 1];
|
||||||
const newUndoStack = history.undoStack.slice(0, -1);
|
const newUndoStack = history.undoStack.slice(0, -1);
|
||||||
|
|
||||||
// Get current state from graphStore and push it to redo stack
|
// Push current state to redo stack
|
||||||
const currentGraphState = useGraphStore.getState();
|
|
||||||
const currentStateSnapshot: GraphSnapshot = {
|
|
||||||
nodes: currentGraphState.nodes,
|
|
||||||
edges: currentGraphState.edges,
|
|
||||||
nodeTypes: currentGraphState.nodeTypes,
|
|
||||||
edgeTypes: currentGraphState.edgeTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newRedoStack = [...history.redoStack];
|
const newRedoStack = [...history.redoStack];
|
||||||
newRedoStack.push({
|
newRedoStack.push({
|
||||||
description: lastAction.description,
|
description: lastAction.description,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
graphState: JSON.parse(JSON.stringify(currentStateSnapshot)), // Deep copy
|
documentState: JSON.parse(JSON.stringify(currentSnapshot, (_key, value) => {
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return Object.fromEntries(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore the previous state (deep copy)
|
// Restore the previous state (deep copy with Map reconstruction)
|
||||||
const restoredState: GraphSnapshot = JSON.parse(
|
const restoredState: DocumentSnapshot = JSON.parse(
|
||||||
JSON.stringify(lastAction.graphState),
|
JSON.stringify(lastAction.documentState),
|
||||||
|
(key, value) => {
|
||||||
|
// Reconstruct Maps from objects
|
||||||
|
if (key === 'states' && value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return new Map(Object.entries(value));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("⏪ after undo:", {
|
console.log("⏪ after undo:", {
|
||||||
restoredStateNodes: restoredState.nodes.length,
|
currentStateId: restoredState.timeline.currentStateId,
|
||||||
restoredStateEdges: restoredState.edges.length,
|
stateCount: restoredState.timeline.states.size,
|
||||||
undoStackSize: newUndoStack.length,
|
undoStackSize: newUndoStack.length,
|
||||||
redoStackSize: newRedoStack.length,
|
redoStackSize: newRedoStack.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
newHistories.set(stateId, {
|
newHistories.set(documentId, {
|
||||||
undoStack: newUndoStack,
|
undoStack: newUndoStack,
|
||||||
redoStack: newRedoStack,
|
redoStack: newRedoStack,
|
||||||
});
|
});
|
||||||
|
|
||||||
set({ histories: newHistories });
|
set({ histories: newHistories });
|
||||||
|
|
||||||
|
// Return the restored state
|
||||||
return restoredState;
|
return restoredState;
|
||||||
},
|
},
|
||||||
|
|
||||||
redo: (stateId: string) => {
|
redo: (documentId: string, currentSnapshot: DocumentSnapshot) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
|
|
||||||
if (!history || history.redoStack.length === 0) {
|
if (!history || history.redoStack.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -222,57 +238,62 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
const lastAction = history.redoStack[history.redoStack.length - 1];
|
const lastAction = history.redoStack[history.redoStack.length - 1];
|
||||||
const newRedoStack = history.redoStack.slice(0, -1);
|
const newRedoStack = history.redoStack.slice(0, -1);
|
||||||
|
|
||||||
// Get current state from graphStore and push it to undo stack
|
// Push current state to undo stack
|
||||||
const currentGraphState = useGraphStore.getState();
|
|
||||||
const currentStateSnapshot: GraphSnapshot = {
|
|
||||||
nodes: currentGraphState.nodes,
|
|
||||||
edges: currentGraphState.edges,
|
|
||||||
nodeTypes: currentGraphState.nodeTypes,
|
|
||||||
edgeTypes: currentGraphState.edgeTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
const newUndoStack = [...history.undoStack];
|
const newUndoStack = [...history.undoStack];
|
||||||
newUndoStack.push({
|
newUndoStack.push({
|
||||||
description: lastAction.description,
|
description: lastAction.description,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
graphState: JSON.parse(JSON.stringify(currentStateSnapshot)), // Deep copy
|
documentState: JSON.parse(JSON.stringify(currentSnapshot, (_key, value) => {
|
||||||
|
if (value instanceof Map) {
|
||||||
|
return Object.fromEntries(value);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trim if exceeds max size
|
// Trim if exceeds max size
|
||||||
if (newUndoStack.length > state.maxHistorySize) {
|
if (newUndoStack.length > state.maxHistorySize) {
|
||||||
newUndoStack.shift(); // Remove oldest
|
newUndoStack.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore the future state (deep copy)
|
// Restore the future state (deep copy with Map reconstruction)
|
||||||
const restoredState: GraphSnapshot = JSON.parse(
|
const restoredState: DocumentSnapshot = JSON.parse(
|
||||||
JSON.stringify(lastAction.graphState),
|
JSON.stringify(lastAction.documentState),
|
||||||
|
(key, value) => {
|
||||||
|
// Reconstruct Maps from objects
|
||||||
|
if (key === 'states' && value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return new Map(Object.entries(value));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
newHistories.set(stateId, {
|
newHistories.set(documentId, {
|
||||||
undoStack: newUndoStack,
|
undoStack: newUndoStack,
|
||||||
redoStack: newRedoStack,
|
redoStack: newRedoStack,
|
||||||
});
|
});
|
||||||
|
|
||||||
set({ histories: newHistories });
|
set({ histories: newHistories });
|
||||||
|
|
||||||
|
// Return the restored state
|
||||||
return restoredState;
|
return restoredState;
|
||||||
},
|
},
|
||||||
|
|
||||||
canUndo: (stateId: string) => {
|
canUndo: (documentId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
return history ? history.undoStack.length > 0 : false;
|
return history ? history.undoStack.length > 0 : false;
|
||||||
},
|
},
|
||||||
|
|
||||||
canRedo: (stateId: string) => {
|
canRedo: (documentId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
return history ? history.redoStack.length > 0 : false;
|
return history ? history.redoStack.length > 0 : false;
|
||||||
},
|
},
|
||||||
|
|
||||||
getUndoDescription: (stateId: string) => {
|
getUndoDescription: (documentId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
|
|
||||||
if (!history || history.undoStack.length === 0) {
|
if (!history || history.undoStack.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -282,9 +303,9 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
return lastAction.description;
|
return lastAction.description;
|
||||||
},
|
},
|
||||||
|
|
||||||
getRedoDescription: (stateId: string) => {
|
getRedoDescription: (documentId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
|
|
||||||
if (!history || history.redoStack.length === 0) {
|
if (!history || history.redoStack.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -294,13 +315,13 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
return lastAction.description;
|
return lastAction.description;
|
||||||
},
|
},
|
||||||
|
|
||||||
clearHistory: (stateId: string) => {
|
clearHistory: (documentId: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newHistories = new Map(state.histories);
|
const newHistories = new Map(state.histories);
|
||||||
const history = newHistories.get(stateId);
|
const history = newHistories.get(documentId);
|
||||||
|
|
||||||
if (history) {
|
if (history) {
|
||||||
newHistories.set(stateId, {
|
newHistories.set(documentId, {
|
||||||
undoStack: [],
|
undoStack: [],
|
||||||
redoStack: [],
|
redoStack: [],
|
||||||
});
|
});
|
||||||
|
|
@ -310,17 +331,17 @@ export const useHistoryStore = create<HistoryStore & HistoryActions>(
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
removeHistory: (stateId: string) => {
|
removeHistory: (documentId: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newHistories = new Map(state.histories);
|
const newHistories = new Map(state.histories);
|
||||||
newHistories.delete(stateId);
|
newHistories.delete(documentId);
|
||||||
return { histories: newHistories };
|
return { histories: newHistories };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
getHistoryStats: (stateId: string) => {
|
getHistoryStats: (documentId: string) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const history = state.histories.get(stateId);
|
const history = state.histories.get(documentId);
|
||||||
|
|
||||||
if (!history) {
|
if (!history) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import type { SerializedActor, SerializedRelation } from "./persistence/types";
|
||||||
import { useGraphStore } from "./graphStore";
|
import { useGraphStore } from "./graphStore";
|
||||||
import { useWorkspaceStore } from "./workspaceStore";
|
import { useWorkspaceStore } from "./workspaceStore";
|
||||||
import { useToastStore } from "./toastStore";
|
import { useToastStore } from "./toastStore";
|
||||||
|
import { useHistoryStore } from "./historyStore";
|
||||||
|
import type { DocumentSnapshot } from "./historyStore";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Timeline Store
|
* Timeline Store
|
||||||
|
|
@ -31,6 +33,35 @@ function generateStateId(): StateId {
|
||||||
return `state_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
return `state_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to push document state to history
|
||||||
|
function pushDocumentHistory(documentId: string, description: string) {
|
||||||
|
const historyStore = useHistoryStore.getState();
|
||||||
|
const timelineStore = useTimelineStore.getState();
|
||||||
|
const graphStore = useGraphStore.getState();
|
||||||
|
|
||||||
|
const timeline = timelineStore.timelines.get(documentId);
|
||||||
|
if (!timeline) {
|
||||||
|
console.warn('No timeline found for document');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: DocumentSnapshot = {
|
||||||
|
timeline: {
|
||||||
|
states: new Map(timeline.states), // Clone the Map
|
||||||
|
currentStateId: timeline.currentStateId,
|
||||||
|
rootStateId: timeline.rootStateId,
|
||||||
|
},
|
||||||
|
nodeTypes: graphStore.nodeTypes,
|
||||||
|
edgeTypes: graphStore.edgeTypes,
|
||||||
|
};
|
||||||
|
|
||||||
|
historyStore.pushAction(documentId, {
|
||||||
|
description,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
documentState: snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
timelines: new Map(),
|
timelines: new Map(),
|
||||||
|
|
@ -126,6 +157,9 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push to history BEFORE making changes
|
||||||
|
pushDocumentHistory(activeDocumentId, `Create State: ${label}`);
|
||||||
|
|
||||||
const newStateId = generateStateId();
|
const newStateId = generateStateId();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
@ -210,6 +244,12 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't push history if already on this state
|
||||||
|
if (timeline.currentStateId !== stateId) {
|
||||||
|
// Push to history BEFORE making changes
|
||||||
|
pushDocumentHistory(activeDocumentId, `Switch to State: ${targetState.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Save current graph state to current state before switching (nodes and edges only)
|
// Save current graph state to current state before switching (nodes and edges only)
|
||||||
const currentState = timeline.states.get(timeline.currentStateId);
|
const currentState = timeline.states.get(timeline.currentStateId);
|
||||||
if (currentState) {
|
if (currentState) {
|
||||||
|
|
@ -262,6 +302,13 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push to history BEFORE making changes (only if label changed)
|
||||||
|
if (updates.label && updates.label !== stateToUpdate.label) {
|
||||||
|
pushDocumentHistory(activeDocumentId, `Rename State: ${stateToUpdate.label} → ${updates.label}`);
|
||||||
|
} else if (updates.description || updates.metadata) {
|
||||||
|
pushDocumentHistory(activeDocumentId, `Update State: ${stateToUpdate.label}`);
|
||||||
|
}
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTimelines = new Map(state.timelines);
|
const newTimelines = new Map(state.timelines);
|
||||||
const timeline = newTimelines.get(activeDocumentId)!;
|
const timeline = newTimelines.get(activeDocumentId)!;
|
||||||
|
|
@ -328,6 +375,9 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
const stateToDelete = timeline.states.get(stateId);
|
const stateToDelete = timeline.states.get(stateId);
|
||||||
const stateName = stateToDelete?.label || "Unknown";
|
const stateName = stateToDelete?.label || "Unknown";
|
||||||
|
|
||||||
|
// Push to history BEFORE making changes
|
||||||
|
pushDocumentHistory(activeDocumentId, `Delete State: ${stateName}`);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newTimelines = new Map(state.timelines);
|
const newTimelines = new Map(state.timelines);
|
||||||
const timeline = newTimelines.get(activeDocumentId)!;
|
const timeline = newTimelines.get(activeDocumentId)!;
|
||||||
|
|
@ -379,6 +429,9 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
|
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
|
||||||
|
|
||||||
|
// Push to history BEFORE making changes
|
||||||
|
pushDocumentHistory(activeDocumentId, `Duplicate State: ${label}`);
|
||||||
|
|
||||||
const duplicatedState: ConstellationState = {
|
const duplicatedState: ConstellationState = {
|
||||||
...stateToDuplicate,
|
...stateToDuplicate,
|
||||||
id: newStateId,
|
id: newStateId,
|
||||||
|
|
@ -440,6 +493,9 @@ export const useTimelineStore = create<TimelineStore & TimelineActions>(
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
|
const label = newLabel || `${stateToDuplicate.label} (Copy)`;
|
||||||
|
|
||||||
|
// Push to history BEFORE making changes
|
||||||
|
pushDocumentHistory(activeDocumentId, `Duplicate State as Child: ${label}`);
|
||||||
|
|
||||||
const duplicatedState: ConstellationState = {
|
const duplicatedState: ConstellationState = {
|
||||||
...stateToDuplicate,
|
...stateToDuplicate,
|
||||||
id: newStateId,
|
id: newStateId,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue