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:
Jan-Henrik Bruhn 2025-10-11 22:38:23 +02:00
parent 227b61b2a0
commit d775cb8863
5 changed files with 411 additions and 208 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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;

View file

@ -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,