From d775cb88633dfbd5a2f7daa62e480d141660cc02 Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sat, 11 Oct 2025 22:38:23 +0200 Subject: [PATCH] refactor: migrate undo/redo from per-state to per-document level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/UNDO_REDO_IMPLEMENTATION.md | 136 ++++++++++++++----- src/hooks/useDocumentHistory.ts | 196 +++++++++++++++++---------- src/hooks/useGraphWithHistory.ts | 8 +- src/stores/historyStore.ts | 223 +++++++++++++++++-------------- src/stores/timelineStore.ts | 56 ++++++++ 5 files changed, 411 insertions(+), 208 deletions(-) diff --git a/docs/UNDO_REDO_IMPLEMENTATION.md b/docs/UNDO_REDO_IMPLEMENTATION.md index abaf29a..94a97e3 100644 --- a/docs/UNDO_REDO_IMPLEMENTATION.md +++ b/docs/UNDO_REDO_IMPLEMENTATION.md @@ -2,14 +2,17 @@ ## 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:** -- ✅ **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) - ✅ **Visual UI**: Undo/Redo buttons in toolbar with disabled states and tooltips - ✅ **Action Descriptions**: Hover tooltips show what action will be undone/redone -- ✅ **Automatic Tracking**: All graph operations are automatically tracked - ✅ **Debounced Moves**: Node dragging is debounced to avoid cluttering history - ✅ **Document Switching**: History is preserved when switching between documents @@ -17,7 +20,7 @@ The Constellation Analyzer now features a comprehensive per-document undo/redo s ### 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 { @@ -27,16 +30,28 @@ The central store manages history for all documents: ``` Each `DocumentHistory` contains: -- `undoStack`: Array of past actions (most recent at end) -- `redoStack`: Array of undone actions that can be redone -- `currentState`: The current document state snapshot +- `undoStack`: Array of past document states (most recent at end) +- `redoStack`: Array of undone document states that can be redone + +Each `DocumentSnapshot` contains: +```typescript +{ + timeline: { + states: Map // 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:** -- `pushAction(documentId, action)`: Records a new action -- `undo(documentId)`: Reverts to previous state -- `redo(documentId)`: Restores undone state +- `pushAction(documentId, action)`: Records complete document snapshot +- `undo(documentId)`: Reverts to previous document state +- `redo(documentId)`: Restores undone document state - `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 ### 2. Document History Hook (`src/hooks/useDocumentHistory.ts`) @@ -49,10 +64,14 @@ const { undo, redo, canUndo, canRedo, undoDescription, redoDescription, pushToHi **Responsibilities:** - Initializes history when document is first loaded -- Provides `pushToHistory(description)` to record actions -- Handles undo/redo by restoring document state -- Updates both graphStore and workspaceStore on undo/redo +- Provides `pushToHistory(description)` to record complete document snapshots +- Handles undo/redo by restoring: + - 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 +- Triggers auto-save after changes ### 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()`. +### 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 ### Recording an Action -1. User performs action (e.g., adds a node) -2. `pushToHistory('Add Person Actor')` is called -3. Current document state is snapshotted +1. User performs action (e.g., "adds a node" or "creates a timeline state") +2. `pushToHistory('Add Person Actor')` or `pushDocumentHistory('Create State: Feature A')` is called +3. **Complete document state** is snapshotted: + - Entire timeline (all states) + - Current state ID + - Global node/edge types 4. Snapshot is pushed to `undoStack` 5. `redoStack` is cleared (since new action invalidates redo) ### Performing Undo 1. User presses Ctrl+Z or clicks Undo button -2. Last action is popped from `undoStack` -3. Current state is pushed to `redoStack` -4. Previous state from action is restored -5. GraphStore and WorkspaceStore are updated -6. Document marked as dirty +2. Last document snapshot is popped from `undoStack` +3. Current document state is pushed to `redoStack` +4. Previous document state is restored: + - Timeline structure loaded into timelineStore + - Types loaded into graphStore + - Current state's graph loaded into graphStore +5. Document marked as dirty and auto-saved ### Performing Redo 1. User presses Ctrl+Y or clicks Redo button -2. Last undone action is popped from `redoStack` -3. Current state is pushed to `undoStack` -4. Future state from undone action is restored -5. GraphStore and WorkspaceStore are updated -6. Document marked as dirty +2. Last undone snapshot is popped from `redoStack` +3. Current document state is pushed to `undoStack` +4. Future document state is restored (same process as undo) +5. Document marked as dirty and auto-saved ## Per-Document Independence **Critical Feature:** Each document has completely separate history. Example workflow: -1. Document A: Add 3 nodes -2. Switch to Document B: Add 2 edges -3. Switch back to Document A: Can still undo those 3 node additions -4. Switch back to Document B: Can still undo those 2 edge additions +1. Document A: Add 3 nodes, create timeline state "Feature X" +2. Switch to Document B: Add 2 edges, switch to "Design 2" state +3. Switch back to Document A: Can undo timeline state creation AND node additions +4. Switch back to Document B: Can undo state switch AND edge additions 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 ### Memory Management diff --git a/src/hooks/useDocumentHistory.ts b/src/hooks/useDocumentHistory.ts index 928199a..8efc315 100644 --- a/src/hooks/useDocumentHistory.ts +++ b/src/hooks/useDocumentHistory.ts @@ -3,15 +3,16 @@ import { useWorkspaceStore } from '../stores/workspaceStore'; import { useHistoryStore } from '../stores/historyStore'; import { useGraphStore } from '../stores/graphStore'; import { useTimelineStore } from '../stores/timelineStore'; -import type { GraphSnapshot } from '../stores/historyStore'; +import type { DocumentSnapshot } from '../stores/historyStore'; /** * useDocumentHistory Hook * - * Provides undo/redo functionality for the active timeline state. - * Each timeline state has its own independent history stack (max 50 actions). + * Provides undo/redo functionality for the active document. + * 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: * const { undo, redo, canUndo, canRedo, pushToHistory } = useDocumentHistory(); @@ -20,83 +21,109 @@ export function useDocumentHistory() { const activeDocumentId = useWorkspaceStore((state) => state.activeDocumentId); const markDocumentDirty = useWorkspaceStore((state) => state.markDocumentDirty); - const setNodes = useGraphStore((state) => state.setNodes); - const setEdges = useGraphStore((state) => state.setEdges); const setNodeTypes = useGraphStore((state) => state.setNodeTypes); const setEdgeTypes = useGraphStore((state) => state.setEdgeTypes); const historyStore = useHistoryStore(); - // Get current timeline state ID - const currentStateId = useTimelineStore((state) => { - if (!activeDocumentId) return null; - const timeline = state.timelines.get(activeDocumentId); - return timeline?.currentStateId || null; - }); - - // Initialize history for active timeline state + // Initialize history for active document useEffect(() => { - if (!currentStateId) return; + if (!activeDocumentId) return; - const history = historyStore.histories.get(currentStateId); + const history = historyStore.histories.get(activeDocumentId); 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( (description: string) => { - if (!currentStateId) { - console.warn('No active timeline state to record action'); + if (!activeDocumentId) { + console.warn('No active document to record action'); return; } - // Read current state directly from store (not from React hooks which might be stale) - const currentState = useGraphStore.getState(); + // Get current state from stores + const graphStore = useGraphStore.getState(); + const timelineStore = useTimelineStore.getState(); + const timeline = timelineStore.timelines.get(activeDocumentId); - // Create a snapshot of the current graph state - const snapshot: GraphSnapshot = { - nodes: currentState.nodes, - edges: currentState.edges, - nodeTypes: currentState.nodeTypes, - edgeTypes: currentState.edgeTypes, + if (!timeline) { + console.warn('No timeline found for active document'); + return; + } + + // 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 - historyStore.pushAction(currentStateId, { + historyStore.pushAction(activeDocumentId, { description, 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(() => { - if (!currentStateId || !activeDocumentId) { - console.warn('No active timeline state to undo'); + if (!activeDocumentId) { + console.warn('No active document to undo'); return; } - const restoredState = historyStore.undo(currentStateId); - if (restoredState) { - // Update graph store with restored state - setNodes(restoredState.nodes as never[]); - setEdges(restoredState.edges as never[]); - setNodeTypes(restoredState.nodeTypes as never[]); - setEdgeTypes(restoredState.edgeTypes as never[]); + // Capture current state BEFORE undoing + const graphStore = useGraphStore.getState(); + const timelineStore = useTimelineStore.getState(); + const timeline = timelineStore.timelines.get(activeDocumentId); - // Update the timeline's current state with the restored graph (nodes and edges only) - useTimelineStore.getState().saveCurrentGraph({ - nodes: restoredState.nodes as never[], - edges: restoredState.edges as never[], - }); + if (!timeline) { + console.warn('No timeline found for active document'); + return; + } + + 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 markDocumentDirty(activeDocumentId); @@ -107,30 +134,55 @@ export function useDocumentHistory() { saveDocument(activeDocumentId); }, 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(() => { - if (!currentStateId || !activeDocumentId) { - console.warn('No active timeline state to redo'); + if (!activeDocumentId) { + console.warn('No active document to redo'); return; } - const restoredState = historyStore.redo(currentStateId); - if (restoredState) { - // Update graph store with restored state - setNodes(restoredState.nodes as never[]); - setEdges(restoredState.edges as never[]); - setNodeTypes(restoredState.nodeTypes as never[]); - setEdgeTypes(restoredState.edgeTypes as never[]); + // Capture current state BEFORE redoing + const graphStore = useGraphStore.getState(); + const timelineStore = useTimelineStore.getState(); + const timeline = timelineStore.timelines.get(activeDocumentId); - // Update the timeline's current state with the restored graph (nodes and edges only) - useTimelineStore.getState().saveCurrentGraph({ - nodes: restoredState.nodes as never[], - edges: restoredState.edges as never[], - }); + if (!timeline) { + console.warn('No timeline found for active document'); + return; + } + + 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 markDocumentDirty(activeDocumentId); @@ -141,30 +193,30 @@ export function useDocumentHistory() { saveDocument(activeDocumentId); }, 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 */ - const undoDescription = currentStateId - ? historyStore.getUndoDescription(currentStateId) + const undoDescription = activeDocumentId + ? historyStore.getUndoDescription(activeDocumentId) : null; /** * Get the description of the next redo action */ - const redoDescription = currentStateId - ? historyStore.getRedoDescription(currentStateId) + const redoDescription = activeDocumentId + ? historyStore.getRedoDescription(activeDocumentId) : null; return { diff --git a/src/hooks/useGraphWithHistory.ts b/src/hooks/useGraphWithHistory.ts index 961b4dc..1a4cbf8 100644 --- a/src/hooks/useGraphWithHistory.ts +++ b/src/hooks/useGraphWithHistory.ts @@ -8,14 +8,14 @@ import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig, RelationData } fr * * ✅ USE THIS HOOK FOR ALL GRAPH MUTATIONS IN COMPONENTS ✅ * - * This hook wraps graph store operations with automatic history tracking. - * Every operation that modifies the graph pushes a snapshot to the history stack, - * enabling undo/redo functionality. + * This hook wraps graph store operations with automatic document-level history tracking. + * Every operation that modifies the graph pushes a complete document snapshot to the history stack, + * enabling undo/redo functionality for both graph changes and timeline operations. * * ⚠️ IMPORTANT: Always use this hook instead of `useGraphStore()` in components * that modify graph state. * - * History-tracked operations: + * History-tracked operations (saved to document-level history): * - Node operations: addNode, updateNode, deleteNode * - Edge operations: addEdge, updateEdge, deleteEdge * - Type operations: addNodeType, updateNodeType, deleteNodeType, addEdgeType, updateEdgeType, deleteEdgeType diff --git a/src/stores/historyStore.ts b/src/stores/historyStore.ts index 9094485..58d25b5 100644 --- a/src/stores/historyStore.ts +++ b/src/stores/historyStore.ts @@ -1,75 +1,84 @@ import { create } from "zustand"; -import { useGraphStore } from "./graphStore"; -import type { Actor, Relation, NodeTypeConfig, EdgeTypeConfig } from "../types"; +import type { 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. - * Tracks all reversible operations: node add/delete/move, edge add/delete/edit, type changes. + * Each document maintains its own independent history stack with a maximum of 50 actions. + * 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 { - nodes: Actor[]; - edges: Relation[]; +export interface DocumentSnapshot { + // Complete timeline structure (all states) + timeline: { + states: Map; + currentStateId: StateId; + rootStateId: StateId; + }; + // Global types (shared across all timeline states) nodeTypes: NodeTypeConfig[]; edgeTypes: EdgeTypeConfig[]; } 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 - 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) redoStack: HistoryAction[]; // Future states to restore (most recent at end) } interface HistoryStore { - // Map of stateId -> history (each timeline state has its own independent history) - histories: Map; + // Map of documentId -> history (each document has its own independent history) + histories: Map; - // Max number of actions to keep in history per state + // Max number of actions to keep in history per document maxHistorySize: number; } interface HistoryActions { - // Initialize history for a timeline state - initializeHistory: (stateId: string) => void; + // Initialize history for a document + initializeHistory: (documentId: string) => void; - // Push a new action onto the state's history stack - pushAction: (stateId: string, action: HistoryAction) => void; + // Push a new action onto the document's history stack + pushAction: (documentId: string, action: HistoryAction) => void; - // Undo the last action for a specific state - undo: (stateId: string) => GraphSnapshot | null; + // Undo the last action for a specific document + undo: (documentId: string, currentSnapshot: DocumentSnapshot) => DocumentSnapshot | null; - // Redo the last undone action for a specific state - redo: (stateId: string) => GraphSnapshot | null; + // Redo the last undone action for a specific document + redo: (documentId: string, currentSnapshot: DocumentSnapshot) => DocumentSnapshot | null; - // Check if undo is available for a state - canUndo: (stateId: string) => boolean; + // Check if undo is available for a document + canUndo: (documentId: string) => boolean; - // Check if redo is available for a state - canRedo: (stateId: string) => boolean; + // Check if redo is available for a document + canRedo: (documentId: string) => boolean; - // Get the description of the next undo action for a state - getUndoDescription: (stateId: string) => string | null; + // Get the description of the next undo action for a document + getUndoDescription: (documentId: string) => string | null; - // Get the description of the next redo action for a state - getRedoDescription: (stateId: string) => string | null; + // Get the description of the next redo action for a document + getRedoDescription: (documentId: string) => string | null; - // Clear history for a specific state - clearHistory: (stateId: string) => void; + // Clear history for a specific document + clearHistory: (documentId: string) => void; - // Remove history for a state (when state is deleted) - removeHistory: (stateId: string) => void; + // Remove history for a document (when document is deleted) + removeHistory: (documentId: string) => void; // Get history stats for debugging - getHistoryStats: (stateId: string) => { + getHistoryStats: (documentId: string) => { undoCount: number; redoCount: number; } | null; @@ -82,13 +91,13 @@ export const useHistoryStore = create( histories: new Map(), maxHistorySize: MAX_HISTORY_SIZE, - initializeHistory: (stateId: string) => { + initializeHistory: (documentId: string) => { set((state) => { const newHistories = new Map(state.histories); // Only initialize if not already present - if (!newHistories.has(stateId)) { - newHistories.set(stateId, { + if (!newHistories.has(documentId)) { + newHistories.set(documentId, { undoStack: [], redoStack: [], }); @@ -98,30 +107,36 @@ export const useHistoryStore = create( }); }, - pushAction: (stateId: string, action: HistoryAction) => { + pushAction: (documentId: string, action: HistoryAction) => { set((state) => { const newHistories = new Map(state.histories); - const history = newHistories.get(stateId); + const history = newHistories.get(documentId); if (!history) { - console.warn(`History not initialized for state ${stateId}`); + console.warn(`History not initialized for document ${documentId}`); return {}; } console.log("📝 pushAction:", { description: action.description, - actionStateNodes: action.graphState.nodes.length, - actionStateEdges: action.graphState.edges.length, + currentStateId: action.documentState.timeline.currentStateId, + stateCount: action.documentState.timeline.states.size, 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 const newUndoStack = [...history.undoStack]; newUndoStack.push({ description: action.description, 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 @@ -132,7 +147,7 @@ export const useHistoryStore = create( // Clear redo stack when a new action is performed (can't redo after new action) const newRedoStack: HistoryAction[] = []; - newHistories.set(stateId, { + newHistories.set(documentId, { undoStack: newUndoStack, redoStack: newRedoStack, }); @@ -140,19 +155,15 @@ export const useHistoryStore = create( console.log("📝 after push:", { description: action.description, newUndoStackSize: newUndoStack.length, - topOfStackNodes: - newUndoStack[newUndoStack.length - 1]?.graphState.nodes.length, - topOfStackEdges: - newUndoStack[newUndoStack.length - 1]?.graphState.edges.length, }); return { histories: newHistories }; }); }, - undo: (stateId: string) => { + undo: (documentId: string, currentSnapshot: DocumentSnapshot) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); if (!history || history.undoStack.length === 0) { return null; @@ -170,47 +181,52 @@ export const useHistoryStore = create( const lastAction = history.undoStack[history.undoStack.length - 1]; const newUndoStack = history.undoStack.slice(0, -1); - // Get current state from graphStore and push it to redo stack - const currentGraphState = useGraphStore.getState(); - const currentStateSnapshot: GraphSnapshot = { - nodes: currentGraphState.nodes, - edges: currentGraphState.edges, - nodeTypes: currentGraphState.nodeTypes, - edgeTypes: currentGraphState.edgeTypes, - }; - + // Push current state to redo stack const newRedoStack = [...history.redoStack]; newRedoStack.push({ description: lastAction.description, 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) - const restoredState: GraphSnapshot = JSON.parse( - JSON.stringify(lastAction.graphState), + // Restore the previous state (deep copy with Map reconstruction) + const restoredState: DocumentSnapshot = JSON.parse( + 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:", { - restoredStateNodes: restoredState.nodes.length, - restoredStateEdges: restoredState.edges.length, + currentStateId: restoredState.timeline.currentStateId, + stateCount: restoredState.timeline.states.size, undoStackSize: newUndoStack.length, redoStackSize: newRedoStack.length, }); - newHistories.set(stateId, { + newHistories.set(documentId, { undoStack: newUndoStack, redoStack: newRedoStack, }); set({ histories: newHistories }); + // Return the restored state return restoredState; }, - redo: (stateId: string) => { + redo: (documentId: string, currentSnapshot: DocumentSnapshot) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); if (!history || history.redoStack.length === 0) { return null; @@ -222,57 +238,62 @@ export const useHistoryStore = create( const lastAction = history.redoStack[history.redoStack.length - 1]; const newRedoStack = history.redoStack.slice(0, -1); - // Get current state from graphStore and push it to undo stack - const currentGraphState = useGraphStore.getState(); - const currentStateSnapshot: GraphSnapshot = { - nodes: currentGraphState.nodes, - edges: currentGraphState.edges, - nodeTypes: currentGraphState.nodeTypes, - edgeTypes: currentGraphState.edgeTypes, - }; - + // Push current state to undo stack const newUndoStack = [...history.undoStack]; newUndoStack.push({ description: lastAction.description, 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 if (newUndoStack.length > state.maxHistorySize) { - newUndoStack.shift(); // Remove oldest + newUndoStack.shift(); } - // Restore the future state (deep copy) - const restoredState: GraphSnapshot = JSON.parse( - JSON.stringify(lastAction.graphState), + // Restore the future state (deep copy with Map reconstruction) + const restoredState: DocumentSnapshot = JSON.parse( + 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, redoStack: newRedoStack, }); set({ histories: newHistories }); + // Return the restored state return restoredState; }, - canUndo: (stateId: string) => { + canUndo: (documentId: string) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); return history ? history.undoStack.length > 0 : false; }, - canRedo: (stateId: string) => { + canRedo: (documentId: string) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); return history ? history.redoStack.length > 0 : false; }, - getUndoDescription: (stateId: string) => { + getUndoDescription: (documentId: string) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); if (!history || history.undoStack.length === 0) { return null; @@ -282,9 +303,9 @@ export const useHistoryStore = create( return lastAction.description; }, - getRedoDescription: (stateId: string) => { + getRedoDescription: (documentId: string) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); if (!history || history.redoStack.length === 0) { return null; @@ -294,13 +315,13 @@ export const useHistoryStore = create( return lastAction.description; }, - clearHistory: (stateId: string) => { + clearHistory: (documentId: string) => { set((state) => { const newHistories = new Map(state.histories); - const history = newHistories.get(stateId); + const history = newHistories.get(documentId); if (history) { - newHistories.set(stateId, { + newHistories.set(documentId, { undoStack: [], redoStack: [], }); @@ -310,17 +331,17 @@ export const useHistoryStore = create( }); }, - removeHistory: (stateId: string) => { + removeHistory: (documentId: string) => { set((state) => { const newHistories = new Map(state.histories); - newHistories.delete(stateId); + newHistories.delete(documentId); return { histories: newHistories }; }); }, - getHistoryStats: (stateId: string) => { + getHistoryStats: (documentId: string) => { const state = get(); - const history = state.histories.get(stateId); + const history = state.histories.get(documentId); if (!history) { return null; diff --git a/src/stores/timelineStore.ts b/src/stores/timelineStore.ts index 783c8ff..46257dd 100644 --- a/src/stores/timelineStore.ts +++ b/src/stores/timelineStore.ts @@ -10,6 +10,8 @@ import type { SerializedActor, SerializedRelation } from "./persistence/types"; import { useGraphStore } from "./graphStore"; import { useWorkspaceStore } from "./workspaceStore"; import { useToastStore } from "./toastStore"; +import { useHistoryStore } from "./historyStore"; +import type { DocumentSnapshot } from "./historyStore"; /** * Timeline Store @@ -31,6 +33,35 @@ function generateStateId(): StateId { 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( (set, get) => ({ timelines: new Map(), @@ -126,6 +157,9 @@ export const useTimelineStore = create( return ""; } + // Push to history BEFORE making changes + pushDocumentHistory(activeDocumentId, `Create State: ${label}`); + const newStateId = generateStateId(); const now = new Date().toISOString(); @@ -210,6 +244,12 @@ export const useTimelineStore = create( 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) const currentState = timeline.states.get(timeline.currentStateId); if (currentState) { @@ -262,6 +302,13 @@ export const useTimelineStore = create( 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) => { const newTimelines = new Map(state.timelines); const timeline = newTimelines.get(activeDocumentId)!; @@ -328,6 +375,9 @@ export const useTimelineStore = create( const stateToDelete = timeline.states.get(stateId); const stateName = stateToDelete?.label || "Unknown"; + // Push to history BEFORE making changes + pushDocumentHistory(activeDocumentId, `Delete State: ${stateName}`); + set((state) => { const newTimelines = new Map(state.timelines); const timeline = newTimelines.get(activeDocumentId)!; @@ -379,6 +429,9 @@ export const useTimelineStore = create( const now = new Date().toISOString(); const label = newLabel || `${stateToDuplicate.label} (Copy)`; + // Push to history BEFORE making changes + pushDocumentHistory(activeDocumentId, `Duplicate State: ${label}`); + const duplicatedState: ConstellationState = { ...stateToDuplicate, id: newStateId, @@ -440,6 +493,9 @@ export const useTimelineStore = create( const now = new Date().toISOString(); const label = newLabel || `${stateToDuplicate.label} (Copy)`; + // Push to history BEFORE making changes + pushDocumentHistory(activeDocumentId, `Duplicate State as Child: ${label}`); + const duplicatedState: ConstellationState = { ...stateToDuplicate, id: newStateId,