308 lines
8.1 KiB
TypeScript
308 lines
8.1 KiB
TypeScript
/**
|
|
* 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<MyState>()(
|
|
* undoRedo(
|
|
* (set, get) => ({
|
|
* // store implementation
|
|
* }),
|
|
* { maxHistorySize: 50 }
|
|
* )
|
|
* );
|
|
* ```
|
|
*/
|
|
export function undoRedo<T extends object>(
|
|
config: StateCreator<T>,
|
|
options: UndoRedoOptions = {},
|
|
): StateCreator<T & { undo: () => 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 (
|
|
* <div>
|
|
* <button onClick={undo} disabled={!canUndo()}>Undo</button>
|
|
* <button onClick={redo} disabled={!canRedo()}>Redo</button>
|
|
* </div>
|
|
* );
|
|
* }
|
|
* ```
|
|
*/
|
|
export function useUndoRedo<T extends { undo: () => 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<string, { undo: () => 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();
|
|
|