veza/apps/web/src/utils/undoRedo.ts

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();