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:
2085
atomizer-dashboard/frontend/package-lock.json
generated
2085
atomizer-dashboard/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,15 +7,20 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@nivo/core": "^0.99.0",
|
||||
"@nivo/parallel-coordinates": "^0.99.0",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.4.0",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-plotly.js": "^2.6.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/three": "^0.181.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
@@ -23,11 +28,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.554.0",
|
||||
"plotly.js-basic-dist": "^3.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-plotly.js": "^2.6.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
@@ -42,18 +45,27 @@
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"@vitest/ui": "^4.0.17",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"jsdom": "^27.4.0",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vitest": "^4.0.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* CodeEditorPanel - Monaco editor for custom extractor Python code
|
||||
*
|
||||
* Features:
|
||||
* - Python syntax highlighting
|
||||
* - Auto-completion for common patterns
|
||||
* - Error display
|
||||
* - Claude AI code generation button
|
||||
* - Preview of extracted outputs
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
|
||||
import {
|
||||
Play,
|
||||
Wand2,
|
||||
Copy,
|
||||
Check,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
X,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileCode,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Monaco editor types
|
||||
type Monaco = Parameters<OnMount>[1];
|
||||
type EditorInstance = Parameters<OnMount>[0];
|
||||
|
||||
interface CodeEditorPanelProps {
|
||||
/** Initial code content */
|
||||
initialCode?: string;
|
||||
/** Callback when code changes */
|
||||
onChange?: (code: string) => void;
|
||||
/** Callback when user requests Claude generation */
|
||||
onRequestGeneration?: (prompt: string) => Promise<string>;
|
||||
/** Whether the panel is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Extractor name for context */
|
||||
extractorName?: string;
|
||||
/** Output variable names */
|
||||
outputs?: string[];
|
||||
/** Optional height (default: 300px) */
|
||||
height?: number | string;
|
||||
/** Show/hide header */
|
||||
showHeader?: boolean;
|
||||
/** Callback when running code (validation) */
|
||||
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
|
||||
/** Close button callback */
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Default Python template for custom extractors
|
||||
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
|
||||
Custom Extractor Function
|
||||
|
||||
This function is called after FEA simulation completes.
|
||||
It receives the results and should return extracted values.
|
||||
|
||||
Available inputs:
|
||||
- op2_path: Path to the .op2 results file
|
||||
- fem_path: Path to the .fem file
|
||||
- params: Dict of current design variable values
|
||||
- subcase_id: Current subcase being analyzed (optional)
|
||||
|
||||
Return a dict with your extracted values.
|
||||
"""
|
||||
|
||||
from pyNastran.op2.op2 import OP2
|
||||
import numpy as np
|
||||
|
||||
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
"""
|
||||
Extract physics from FEA results.
|
||||
|
||||
Args:
|
||||
op2_path: Path to OP2 results file
|
||||
fem_path: Path to FEM file
|
||||
params: Current design variable values
|
||||
subcase_id: Subcase ID to analyze
|
||||
|
||||
Returns:
|
||||
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
|
||||
"""
|
||||
# Load OP2 results
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
# Example: Extract max displacement
|
||||
if subcase_id in op2.displacements:
|
||||
disp = op2.displacements[subcase_id]
|
||||
# Get magnitude of displacement vectors
|
||||
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
|
||||
max_disp = float(np.max(magnitudes))
|
||||
else:
|
||||
max_disp = 0.0
|
||||
|
||||
return {
|
||||
'max_displacement': max_disp,
|
||||
# Add more outputs as needed
|
||||
}
|
||||
`;
|
||||
|
||||
export function CodeEditorPanel({
|
||||
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
|
||||
onChange,
|
||||
onRequestGeneration,
|
||||
readOnly = false,
|
||||
extractorName = 'custom_extractor',
|
||||
outputs = [],
|
||||
height = 400,
|
||||
showHeader = true,
|
||||
onRun,
|
||||
onClose,
|
||||
}: CodeEditorPanelProps) {
|
||||
const [code, setCode] = useState(initialCode);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showPromptInput, setShowPromptInput] = useState(false);
|
||||
const [generationPrompt, setGenerationPrompt] = useState('');
|
||||
const [showOutputs, setShowOutputs] = useState(true);
|
||||
|
||||
const editorRef = useRef<EditorInstance | null>(null);
|
||||
const monacoRef = useRef<Monaco | null>(null);
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monaco;
|
||||
|
||||
// Configure Python language
|
||||
monaco.languages.registerCompletionItemProvider('python', {
|
||||
provideCompletionItems: (model: Parameters<typeof monaco.editor.createModel>[0], position: { lineNumber: number; column: number }) => {
|
||||
const word = model.getWordUntilPosition(position);
|
||||
const range = {
|
||||
startLineNumber: position.lineNumber,
|
||||
endLineNumber: position.lineNumber,
|
||||
startColumn: word.startColumn,
|
||||
endColumn: word.endColumn,
|
||||
};
|
||||
|
||||
const suggestions = [
|
||||
{
|
||||
label: 'op2.read_op2',
|
||||
kind: monaco.languages.CompletionItemKind.Method,
|
||||
insertText: 'op2.read_op2(op2_path)',
|
||||
documentation: 'Read OP2 results file',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'op2.displacements',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'op2.displacements[subcase_id]',
|
||||
documentation: 'Access displacement results for a subcase',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'op2.eigenvectors',
|
||||
kind: monaco.languages.CompletionItemKind.Property,
|
||||
insertText: 'op2.eigenvectors[subcase_id]',
|
||||
documentation: 'Access eigenvector results for modal analysis',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'np.max',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'np.max(${1:array})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Get maximum value from array',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'np.sqrt',
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
insertText: 'np.sqrt(${1:array})',
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Square root of array elements',
|
||||
range,
|
||||
},
|
||||
{
|
||||
label: 'extract_function',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: `def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
|
||||
"""Extract physics from FEA results."""
|
||||
op2 = OP2()
|
||||
op2.read_op2(op2_path)
|
||||
|
||||
# Your extraction logic here
|
||||
|
||||
return {
|
||||
'\${1:output_name}': \${2:value},
|
||||
}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'Insert a complete extract function template',
|
||||
range,
|
||||
},
|
||||
];
|
||||
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Handle code change
|
||||
const handleCodeChange: OnChange = (value) => {
|
||||
const newCode = value || '';
|
||||
setCode(newCode);
|
||||
setError(null);
|
||||
setRunResult(null);
|
||||
onChange?.(newCode);
|
||||
};
|
||||
|
||||
// Copy code to clipboard
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
// Request Claude generation
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!onRequestGeneration || !generationPrompt.trim()) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const generatedCode = await onRequestGeneration(generationPrompt);
|
||||
setCode(generatedCode);
|
||||
onChange?.(generatedCode);
|
||||
setShowPromptInput(false);
|
||||
setGenerationPrompt('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Generation failed');
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [onRequestGeneration, generationPrompt, onChange]);
|
||||
|
||||
// Run/validate code
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!onRun) return;
|
||||
|
||||
setIsRunning(true);
|
||||
setError(null);
|
||||
setRunResult(null);
|
||||
|
||||
try {
|
||||
const result = await onRun(code);
|
||||
setRunResult(result);
|
||||
if (!result.success && result.error) {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Validation failed');
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}, [code, onRun]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-dark-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode size={16} className="text-emerald-400" />
|
||||
<span className="font-medium text-white text-sm">{extractorName}</span>
|
||||
<span className="text-xs text-dark-500">.py</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Claude Generate Button */}
|
||||
{onRequestGeneration && (
|
||||
<button
|
||||
onClick={() => setShowPromptInput(!showPromptInput)}
|
||||
className="p-1.5 rounded text-violet-400 hover:bg-violet-500/20 transition-colors"
|
||||
title="Generate with Claude"
|
||||
>
|
||||
<Sparkles size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
|
||||
</button>
|
||||
|
||||
{/* Run Button */}
|
||||
{onRun && (
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
|
||||
title="Validate code"
|
||||
>
|
||||
{isRunning ? (
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={16} />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Prompt Input */}
|
||||
{showPromptInput && (
|
||||
<div className="px-4 py-3 border-b border-dark-700 bg-violet-500/5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Wand2 size={14} className="text-violet-400" />
|
||||
<span className="text-xs text-violet-400 font-medium">Generate with Claude</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={generationPrompt}
|
||||
onChange={(e) => setGenerationPrompt(e.target.value)}
|
||||
placeholder="Describe what you want to extract... e.g., 'Extract maximum von Mises stress and total mass from the model'"
|
||||
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 resize-none focus:outline-none focus:border-violet-500"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => setShowPromptInput(false)}
|
||||
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !generationPrompt.trim()}
|
||||
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={12} />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 flex items-center gap-2">
|
||||
<AlertCircle size={14} className="text-red-400 flex-shrink-0" />
|
||||
<span className="text-xs text-red-400 font-mono">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Monaco Editor */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Editor
|
||||
height={height}
|
||||
language="python"
|
||||
theme="vs-dark"
|
||||
value={code}
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorMount}
|
||||
options={{
|
||||
readOnly,
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
automaticLayout: true,
|
||||
tabSize: 4,
|
||||
insertSpaces: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Outputs Preview */}
|
||||
{(outputs.length > 0 || runResult?.outputs) && (
|
||||
<div className="border-t border-dark-700">
|
||||
<button
|
||||
onClick={() => setShowOutputs(!showOutputs)}
|
||||
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-800/50 transition-colors"
|
||||
>
|
||||
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
<span>Outputs</span>
|
||||
<span className="ml-auto text-dark-500">
|
||||
{runResult?.outputs
|
||||
? Object.keys(runResult.outputs).length
|
||||
: outputs.length}
|
||||
</span>
|
||||
</button>
|
||||
{showOutputs && (
|
||||
<div className="px-4 pb-3 space-y-1">
|
||||
{runResult?.outputs ? (
|
||||
Object.entries(runResult.outputs).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
|
||||
>
|
||||
<span className="text-emerald-400 font-mono">{key}</span>
|
||||
<span className="text-dark-300">{String(value)}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
outputs.map((output) => (
|
||||
<div
|
||||
key={output}
|
||||
className="flex items-center px-2 py-1 bg-dark-800 rounded text-xs"
|
||||
>
|
||||
<span className="text-dark-400 font-mono">{output}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodeEditorPanel;
|
||||
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;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal } from 'lucide-react';
|
||||
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react';
|
||||
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
|
||||
import { SpecRenderer } from '../components/canvas/SpecRenderer';
|
||||
import { NodePalette } from '../components/canvas/palette/NodePalette';
|
||||
@@ -12,6 +12,7 @@ import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2
|
||||
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
|
||||
import { useCanvasStore } from '../hooks/useCanvasStore';
|
||||
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
|
||||
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { useChat } from '../hooks/useChat';
|
||||
import { CanvasTemplate } from '../lib/canvas/templates';
|
||||
@@ -54,6 +55,13 @@ export function CanvasView() {
|
||||
const { setSelectedStudy, studies } = useStudy();
|
||||
const { clearSpec, setSpecFromWebSocket } = useSpecStore();
|
||||
|
||||
// Undo/Redo for spec mode
|
||||
const undoRedo = useSpecUndoRedo();
|
||||
const { undo, redo, canUndo, canRedo, historyLength } = undoRedo;
|
||||
|
||||
// Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y)
|
||||
useUndoRedoKeyboard(undoRedo);
|
||||
|
||||
// Active study ID comes ONLY from URL - don't auto-load from context
|
||||
// This ensures /canvas shows empty canvas, /canvas/{id} shows the study
|
||||
const activeStudyId = urlStudyId;
|
||||
@@ -318,6 +326,39 @@ export function CanvasView() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Undo/Redo Buttons (spec mode only) */}
|
||||
{useSpecMode && activeStudyId && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-dark-600" />
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
canUndo
|
||||
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
|
||||
: 'text-dark-600 cursor-not-allowed'
|
||||
}`}
|
||||
title={`Undo (Ctrl+Z)${historyLength > 0 ? ` - ${historyLength} steps` : ''}`}
|
||||
>
|
||||
<Undo2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
canRedo
|
||||
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
|
||||
: 'text-dark-600 cursor-not-allowed'
|
||||
}`}
|
||||
title="Redo (Ctrl+Y)"
|
||||
>
|
||||
<Redo2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowTemplates(true)}
|
||||
className="px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5"
|
||||
|
||||
Reference in New Issue
Block a user