feat(canvas): Studio Enhancement Phase 3 & 4 - undo/redo and Monaco editor
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
This commit is contained in:
129
atomizer-dashboard/frontend/src/hooks/useSpecUndoRedo.ts
Normal file
129
atomizer-dashboard/frontend/src/hooks/useSpecUndoRedo.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* useSpecUndoRedo - Undo/Redo for AtomizerSpec changes
|
||||
*
|
||||
* Integrates with useSpecStore to provide undo/redo functionality
|
||||
* with localStorage persistence.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* const { undo, redo, canUndo, canRedo } = useSpecUndoRedo();
|
||||
*
|
||||
* // In keyboard handler:
|
||||
* if (e.ctrlKey && e.key === 'z') undo();
|
||||
* if (e.ctrlKey && e.key === 'y') redo();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useUndoRedo, UndoRedoResult } from './useUndoRedo';
|
||||
import { useSpecStore, useSpec, useSpecIsDirty } from './useSpecStore';
|
||||
import { AtomizerSpec } from '../types/atomizer-spec';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'atomizer-spec-history-';
|
||||
|
||||
export interface SpecUndoRedoResult extends UndoRedoResult<AtomizerSpec | null> {
|
||||
/** The current study ID */
|
||||
studyId: string | null;
|
||||
}
|
||||
|
||||
export function useSpecUndoRedo(): SpecUndoRedoResult {
|
||||
const spec = useSpec();
|
||||
const isDirty = useSpecIsDirty();
|
||||
const studyId = useSpecStore((state) => state.studyId);
|
||||
const lastSpecRef = useRef<AtomizerSpec | null>(null);
|
||||
|
||||
// Storage key includes study ID for per-study history
|
||||
const storageKey = studyId ? `${STORAGE_KEY_PREFIX}${studyId}` : undefined;
|
||||
|
||||
const undoRedo = useUndoRedo<AtomizerSpec | null>({
|
||||
getState: () => useSpecStore.getState().spec,
|
||||
setState: (state) => {
|
||||
if (state) {
|
||||
// Use setSpecFromWebSocket to avoid API call during undo/redo
|
||||
useSpecStore.getState().setSpecFromWebSocket(state, studyId || undefined);
|
||||
}
|
||||
},
|
||||
storageKey,
|
||||
maxHistory: 30, // Keep 30 undo steps per study
|
||||
debounceMs: 1000, // Wait 1s after last change before recording
|
||||
isEqual: (a, b) => {
|
||||
if (a === null && b === null) return true;
|
||||
if (a === null || b === null) return false;
|
||||
// Compare relevant parts of spec (ignore meta.modified timestamps)
|
||||
const aClean = { ...a, meta: { ...a.meta, modified: undefined } };
|
||||
const bClean = { ...b, meta: { ...b.meta, modified: undefined } };
|
||||
return JSON.stringify(aClean) === JSON.stringify(bClean);
|
||||
},
|
||||
});
|
||||
|
||||
// Record snapshot when spec changes (and is dirty)
|
||||
useEffect(() => {
|
||||
if (spec && isDirty && spec !== lastSpecRef.current) {
|
||||
lastSpecRef.current = spec;
|
||||
undoRedo.recordSnapshot();
|
||||
}
|
||||
}, [spec, isDirty, undoRedo]);
|
||||
|
||||
// Clear history when study changes
|
||||
useEffect(() => {
|
||||
if (studyId) {
|
||||
// Don't clear - we're loading per-study history from localStorage
|
||||
// Just reset the ref
|
||||
lastSpecRef.current = spec;
|
||||
}
|
||||
}, [studyId]);
|
||||
|
||||
return {
|
||||
...undoRedo,
|
||||
studyId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle keyboard shortcuts for undo/redo
|
||||
*/
|
||||
export function useUndoRedoKeyboard(
|
||||
undoRedo: Pick<UndoRedoResult<unknown>, 'undo' | 'redo' | 'canUndo' | 'canRedo'>
|
||||
) {
|
||||
const { undo, redo, canUndo, canRedo } = undoRedo;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Z or Cmd+Z for undo
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) {
|
||||
undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Y or Cmd+Shift+Z for redo
|
||||
if (
|
||||
((e.ctrlKey || e.metaKey) && e.key === 'y') ||
|
||||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
}
|
||||
|
||||
export default useSpecUndoRedo;
|
||||
260
atomizer-dashboard/frontend/src/hooks/useUndoRedo.ts
Normal file
260
atomizer-dashboard/frontend/src/hooks/useUndoRedo.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user