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:
2026-01-20 11:58:21 -05:00
parent c4a3cff91a
commit ffd41e3a60
6 changed files with 2926 additions and 62 deletions

View 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;

View 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;