Phase 3 - Undo/Redo System: - Create generic useUndoRedo hook with configurable options - Add localStorage persistence for per-study history (max 30 steps) - Create useSpecUndoRedo hook integrating with useSpecStore - Add useUndoRedoKeyboard hook for Ctrl+Z/Ctrl+Y shortcuts - Add undo/redo buttons to canvas header with tooltips - Debounced history recording (1s delay after changes) Phase 4 - Monaco Code Editor: - Create CodeEditorPanel component with Monaco editor - Add Python syntax highlighting and auto-completion - Include pyNastran/OP2 specific completions - Add Claude AI code generation integration (placeholder) - Include code validation/run functionality - Show output variables preview section - Add copy-to-clipboard and generation prompt UI Dependencies: - Add @monaco-editor/react package Technical: - All TypeScript checks pass - All 15 unit tests pass - Production build successful
261 lines
6.7 KiB
TypeScript
261 lines
6.7 KiB
TypeScript
/**
|
|
* useUndoRedo - Generic undo/redo hook for Zustand stores
|
|
*
|
|
* Features:
|
|
* - History tracking with configurable max size
|
|
* - Undo/redo operations
|
|
* - localStorage persistence (optional)
|
|
* - Debounced history recording
|
|
* - Clear history on demand
|
|
*
|
|
* Usage:
|
|
* ```tsx
|
|
* const { undo, redo, canUndo, canRedo, recordSnapshot } = useUndoRedo({
|
|
* getState: () => myStore.getState().data,
|
|
* setState: (state) => myStore.setState({ data: state }),
|
|
* storageKey: 'my-store-history',
|
|
* });
|
|
* ```
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
|
|
export interface UndoRedoOptions<T> {
|
|
/** Function to get current state snapshot */
|
|
getState: () => T;
|
|
/** Function to restore a state snapshot */
|
|
setState: (state: T) => void;
|
|
/** Maximum history size (default: 50) */
|
|
maxHistory?: number;
|
|
/** localStorage key for persistence (optional) */
|
|
storageKey?: string;
|
|
/** Debounce delay in ms for recording (default: 500) */
|
|
debounceMs?: number;
|
|
/** Custom equality check (default: JSON.stringify comparison) */
|
|
isEqual?: (a: T, b: T) => boolean;
|
|
}
|
|
|
|
export interface UndoRedoResult<T> {
|
|
/** Undo the last change */
|
|
undo: () => void;
|
|
/** Redo the last undone change */
|
|
redo: () => void;
|
|
/** Whether undo is available */
|
|
canUndo: boolean;
|
|
/** Whether redo is available */
|
|
canRedo: boolean;
|
|
/** Manually record a state snapshot */
|
|
recordSnapshot: () => void;
|
|
/** Clear all history */
|
|
clearHistory: () => void;
|
|
/** Current history length */
|
|
historyLength: number;
|
|
/** Current position in history */
|
|
historyPosition: number;
|
|
/** Get history for debugging */
|
|
getHistory: () => T[];
|
|
}
|
|
|
|
interface HistoryState<T> {
|
|
past: T[];
|
|
future: T[];
|
|
}
|
|
|
|
const DEFAULT_MAX_HISTORY = 50;
|
|
const DEFAULT_DEBOUNCE_MS = 500;
|
|
|
|
function defaultIsEqual<T>(a: T, b: T): boolean {
|
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
}
|
|
|
|
export function useUndoRedo<T>(options: UndoRedoOptions<T>): UndoRedoResult<T> {
|
|
const {
|
|
getState,
|
|
setState,
|
|
maxHistory = DEFAULT_MAX_HISTORY,
|
|
storageKey,
|
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
isEqual = defaultIsEqual,
|
|
} = options;
|
|
|
|
// Initialize history from localStorage if available
|
|
const getInitialHistory = (): HistoryState<T> => {
|
|
if (storageKey) {
|
|
try {
|
|
const stored = localStorage.getItem(storageKey);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
// Validate structure
|
|
if (Array.isArray(parsed.past) && Array.isArray(parsed.future)) {
|
|
return parsed;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to load undo history from localStorage:', e);
|
|
}
|
|
}
|
|
return { past: [], future: [] };
|
|
};
|
|
|
|
const [history, setHistory] = useState<HistoryState<T>>(getInitialHistory);
|
|
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const lastRecordedStateRef = useRef<T | null>(null);
|
|
const isUndoRedoRef = useRef(false);
|
|
|
|
// Persist to localStorage when history changes
|
|
useEffect(() => {
|
|
if (storageKey) {
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify(history));
|
|
} catch (e) {
|
|
console.warn('Failed to save undo history to localStorage:', e);
|
|
}
|
|
}
|
|
}, [history, storageKey]);
|
|
|
|
// Record a snapshot to history
|
|
const recordSnapshot = useCallback(() => {
|
|
if (isUndoRedoRef.current) {
|
|
// Don't record during undo/redo operations
|
|
return;
|
|
}
|
|
|
|
const currentState = getState();
|
|
|
|
// Skip if state hasn't changed
|
|
if (lastRecordedStateRef.current !== null && isEqual(lastRecordedStateRef.current, currentState)) {
|
|
return;
|
|
}
|
|
|
|
lastRecordedStateRef.current = currentState;
|
|
|
|
setHistory((prev) => {
|
|
// Create a deep copy for history
|
|
const snapshot = JSON.parse(JSON.stringify(currentState)) as T;
|
|
|
|
const newPast = [...prev.past, snapshot];
|
|
|
|
// Trim history if too long
|
|
if (newPast.length > maxHistory) {
|
|
newPast.shift();
|
|
}
|
|
|
|
return {
|
|
past: newPast,
|
|
future: [], // Clear redo stack on new changes
|
|
};
|
|
});
|
|
}, [getState, maxHistory, isEqual]);
|
|
|
|
// Debounced recording
|
|
const recordSnapshotDebounced = useCallback(() => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
|
|
debounceTimerRef.current = setTimeout(() => {
|
|
recordSnapshot();
|
|
}, debounceMs);
|
|
}, [recordSnapshot, debounceMs]);
|
|
|
|
// Undo operation
|
|
const undo = useCallback(() => {
|
|
setHistory((prev) => {
|
|
if (prev.past.length === 0) {
|
|
return prev;
|
|
}
|
|
|
|
const newPast = [...prev.past];
|
|
const previousState = newPast.pop()!;
|
|
|
|
// Save current state to future before undoing
|
|
const currentState = JSON.parse(JSON.stringify(getState())) as T;
|
|
|
|
isUndoRedoRef.current = true;
|
|
setState(previousState);
|
|
lastRecordedStateRef.current = previousState;
|
|
|
|
// Reset flag after a tick
|
|
setTimeout(() => {
|
|
isUndoRedoRef.current = false;
|
|
}, 0);
|
|
|
|
return {
|
|
past: newPast,
|
|
future: [currentState, ...prev.future],
|
|
};
|
|
});
|
|
}, [getState, setState]);
|
|
|
|
// Redo operation
|
|
const redo = useCallback(() => {
|
|
setHistory((prev) => {
|
|
if (prev.future.length === 0) {
|
|
return prev;
|
|
}
|
|
|
|
const newFuture = [...prev.future];
|
|
const nextState = newFuture.shift()!;
|
|
|
|
// Save current state to past before redoing
|
|
const currentState = JSON.parse(JSON.stringify(getState())) as T;
|
|
|
|
isUndoRedoRef.current = true;
|
|
setState(nextState);
|
|
lastRecordedStateRef.current = nextState;
|
|
|
|
// Reset flag after a tick
|
|
setTimeout(() => {
|
|
isUndoRedoRef.current = false;
|
|
}, 0);
|
|
|
|
return {
|
|
past: [...prev.past, currentState],
|
|
future: newFuture,
|
|
};
|
|
});
|
|
}, [getState, setState]);
|
|
|
|
// Clear history
|
|
const clearHistory = useCallback(() => {
|
|
setHistory({ past: [], future: [] });
|
|
lastRecordedStateRef.current = null;
|
|
|
|
if (storageKey) {
|
|
try {
|
|
localStorage.removeItem(storageKey);
|
|
} catch (e) {
|
|
console.warn('Failed to clear undo history from localStorage:', e);
|
|
}
|
|
}
|
|
}, [storageKey]);
|
|
|
|
// Get history for debugging
|
|
const getHistory = useCallback(() => {
|
|
return [...history.past];
|
|
}, [history.past]);
|
|
|
|
// Cleanup debounce timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceTimerRef.current) {
|
|
clearTimeout(debounceTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
undo,
|
|
redo,
|
|
canUndo: history.past.length > 0,
|
|
canRedo: history.future.length > 0,
|
|
recordSnapshot: recordSnapshotDebounced,
|
|
clearHistory,
|
|
historyLength: history.past.length,
|
|
historyPosition: history.past.length,
|
|
getHistory,
|
|
};
|
|
}
|
|
|
|
export default useUndoRedo;
|