/** * Undo/Redo Utility * FE-STATE-006: Add undo/redo functionality for state changes * * Provides middleware and utilities for implementing undo/redo functionality * in Zustand stores */ import { StateCreator, StoreMutatorIdentifier } from 'zustand'; import { logger } from './logger'; /** * Options for undo/redo middleware */ export interface UndoRedoOptions { /** Maximum number of history entries (default: 50) */ maxHistorySize?: number; /** Whether to enable undo/redo (default: true) */ enabled?: boolean; /** Function to determine if a state change should be tracked */ shouldTrack?: (state: any, prevState: any) => boolean; /** Function to serialize state for history (for memory optimization) */ serialize?: (state: any) => any; /** Function to deserialize state from history */ deserialize?: (serialized: any) => any; } /** * History entry */ interface HistoryEntry { state: any; timestamp: number; } /** * Undo/Redo state */ interface UndoRedoState { history: HistoryEntry[]; currentIndex: number; maxSize: number; } /** * Zustand middleware for undo/redo functionality * * @example * ```typescript * export const useMyStore = create()( * undoRedo( * (set, get) => ({ * // store implementation * }), * { maxHistorySize: 50 } * ) * ); * ``` */ export function undoRedo( config: StateCreator, options: UndoRedoOptions = {}, ): StateCreator void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }> { const { maxHistorySize = 50, enabled = true, shouldTrack = () => true, serialize = (state) => JSON.parse(JSON.stringify(state)), deserialize = (serialized) => serialized, } = options; // History storage (outside the store to persist across updates) const historyState: UndoRedoState = { history: [], currentIndex: -1, maxSize: maxHistorySize, }; return (set, get, api) => { let previousState: T | null = null; let isUndoRedo = false; // Create the store const store = config( (...args) => { if (!isUndoRedo) { set(...args); // Track state changes if (enabled) { const currentState = get(); if (previousState !== null && shouldTrack(currentState, previousState)) { // Remove any future history if we're not at the end if (historyState.currentIndex < historyState.history.length - 1) { historyState.history = historyState.history.slice(0, historyState.currentIndex + 1); } // Add new history entry historyState.history.push({ state: serialize(previousState), timestamp: Date.now(), }); // Limit history size if (historyState.history.length > historyState.maxSize) { historyState.history.shift(); } else { historyState.currentIndex++; } logger.debug('[UndoRedo] State change tracked', { historySize: historyState.history.length, currentIndex: historyState.currentIndex, }); } previousState = serialize(currentState); } } else { set(...args); } }, get, api, ); // Add undo/redo methods return { ...store, undo: () => { if (!enabled || historyState.currentIndex < 0) { return; } isUndoRedo = true; const entry = historyState.history[historyState.currentIndex]; if (entry) { const restoredState = deserialize(entry.state); set(() => restoredState); historyState.currentIndex--; previousState = serialize(restoredState); logger.debug('[UndoRedo] Undone', { currentIndex: historyState.currentIndex, }); } isUndoRedo = false; }, redo: () => { if (!enabled || historyState.currentIndex >= historyState.history.length - 1) { return; } isUndoRedo = true; historyState.currentIndex++; const entry = historyState.history[historyState.currentIndex]; if (entry) { const restoredState = deserialize(entry.state); set(() => restoredState); previousState = serialize(restoredState); logger.debug('[UndoRedo] Redone', { currentIndex: historyState.currentIndex, }); } isUndoRedo = false; }, canUndo: () => { return enabled && historyState.currentIndex >= 0; }, canRedo: () => { return enabled && historyState.currentIndex < historyState.history.length - 1; }, } as T & { undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }; }; } /** * FE-STATE-006: Hook to use undo/redo functionality * * @example * ```typescript * function MyComponent() { * const { undo, redo, canUndo, canRedo } = useUndoRedo(useMyStore); * * return ( *
* * *
* ); * } * ``` */ export function useUndoRedo void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }>( store: () => T, ): { undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; } { const storeInstance = store(); return { undo: storeInstance.undo, redo: storeInstance.redo, canUndo: storeInstance.canUndo(), canRedo: storeInstance.canRedo(), }; } /** * FE-STATE-006: Global undo/redo manager * * Manages undo/redo across multiple stores */ class UndoRedoManager { private stores: Map void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }> = new Map(); /** * Register a store for undo/redo */ register(storeName: string, store: { undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => boolean }): void { this.stores.set(storeName, store); } /** * Unregister a store */ unregister(storeName: string): void { this.stores.delete(storeName); } /** * Undo last change in a specific store or all stores */ undo(storeName?: string): void { if (storeName) { const store = this.stores.get(storeName); if (store && store.canUndo()) { store.undo(); } } else { // Undo in all stores (find the most recent change) let mostRecentStore: string | null = null; let mostRecentTime = 0; for (const [name, store] of this.stores.entries()) { if (store.canUndo()) { // We don't have timestamp info, so just undo in first available store // In a real implementation, you'd track timestamps mostRecentStore = name; break; } } if (mostRecentStore) { this.stores.get(mostRecentStore)?.undo(); } } } /** * Redo last undone change in a specific store or all stores */ redo(storeName?: string): void { if (storeName) { const store = this.stores.get(storeName); if (store && store.canRedo()) { store.redo(); } } else { // Redo in all stores for (const store of this.stores.values()) { if (store.canRedo()) { store.redo(); break; // Only redo in one store at a time } } } } /** * Check if undo is available */ canUndo(storeName?: string): boolean { if (storeName) { const store = this.stores.get(storeName); return store ? store.canUndo() : false; } return Array.from(this.stores.values()).some((store) => store.canUndo()); } /** * Check if redo is available */ canRedo(storeName?: string): boolean { if (storeName) { const store = this.stores.get(storeName); return store ? store.canRedo() : false; } return Array.from(this.stores.values()).some((store) => store.canRedo()); } } // Global instance export const undoRedoManager = new UndoRedoManager();