feat: implement undo/redo functionality with keyboard shortcuts

Implemented a clean, working undo/redo system using useRef to avoid React state management issues.

## Features Added
- **Undo**: Ctrl+Z (Cmd+Z on Mac) to undo the last action
- **Redo**: Ctrl+Shift+Z (Cmd+Shift+Z on Mac) to redo
- **Delete**: Delete key to remove selected shape
- **Escape**: Escape key to deselect
- UI buttons for undo/redo with proper enabled/disabled states

## Technical Implementation
- Uses `useRef` instead of `useState` for history to avoid stale closures
- Deep clones shapes when saving to prevent reference issues
- Maintains up to 50 history states (auto-prunes oldest)
- Properly initializes history with current canvas state on mount
- Uses `e.code === 'KeyZ'` for keyboard detection (shift-independent)
- Includes propagator cleanup when deleting arrows via Delete key

## Key Design Decisions
- **useRef over useState**: Avoids complex state synchronization and stale closure bugs
- **Deep cloning**: JSON.parse(JSON.stringify()) ensures each history entry is independent
- **Initialization guard**: Prevents saving to history before component fully initializes
- **Force re-render**: Uses dummy state update to refresh button disabled states

## Architecture
```typescript
historyRef.current = [state0, state1, state2, ...]
historyIndexRef.current = currentIndex
```

Operations:
- saveToHistory(): Truncates future, adds new state, increments index
- undo(): Decrements index, restores previous state
- redo(): Increments index, restores next state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shawn Anderson 2025-11-09 11:07:29 -08:00
parent 97a4eee6f0
commit de07dabb36
1 changed files with 142 additions and 5 deletions

View File

@ -163,6 +163,112 @@ export default function ItalismPage() {
const [editingArrow, setEditingArrow] = useState<string | null>(null) const [editingArrow, setEditingArrow] = useState<string | null>(null)
const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map()) const [eventTargets, setEventTargets] = useState<Map<string, { source: EventTarget; target: EventTarget }>>(new Map())
// Undo/Redo state - using useRef to avoid stale closure issues
const historyRef = useRef<Shape[][]>([])
const historyIndexRef = useRef(-1)
const [, forceUpdate] = useState({})
const isInitialized = useRef(false)
// Initialize history with current shapes on mount
useEffect(() => {
if (!isInitialized.current) {
historyRef.current = [JSON.parse(JSON.stringify(shapes))]
historyIndexRef.current = 0
isInitialized.current = true
}
}, [])
// Save state to history (called after any shape modification)
const saveToHistory = (newShapes: Shape[]) => {
if (!isInitialized.current) return
// Truncate history after current index (discard redo states)
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1)
// Add new state (deep clone to prevent reference issues)
historyRef.current.push(JSON.parse(JSON.stringify(newShapes)))
// Limit to 50 states to prevent memory issues
if (historyRef.current.length > 50) {
historyRef.current.shift()
} else {
historyIndexRef.current++
}
setShapes(newShapes)
}
// Undo function - go back one state
const undo = () => {
if (historyIndexRef.current > 0) {
historyIndexRef.current--
const previousState = historyRef.current[historyIndexRef.current]
setShapes(previousState)
forceUpdate({}) // Force re-render to update button states
}
}
// Redo function - go forward one state
const redo = () => {
if (historyIndexRef.current < historyRef.current.length - 1) {
historyIndexRef.current++
const nextState = historyRef.current[historyIndexRef.current]
setShapes(nextState)
forceUpdate({}) // Force re-render to update button states
}
}
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ctrl+Z or Cmd+Z for undo
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && !e.shiftKey) {
e.preventDefault()
undo()
}
// Ctrl+Shift+Z or Cmd+Shift+Z for redo
else if ((e.ctrlKey || e.metaKey) && e.code === 'KeyZ' && e.shiftKey) {
e.preventDefault()
redo()
}
// Delete key to delete selected shape
else if (e.key === 'Delete' && selectedShape) {
e.preventDefault()
const clicked = shapes.find(s => s.id === selectedShape)
if (clicked) {
// Cleanup propagator if deleting an arrow
if (clicked.type === "arrow") {
const propagator = propagators.get(clicked.id)
if (propagator) {
propagator.dispose()
setPropagators((prev) => {
const next = new Map(prev)
next.delete(clicked.id)
return next
})
}
setEventTargets((prev) => {
const next = new Map(prev)
next.delete(clicked.id)
return next
})
}
const newShapes = shapes.filter((shape) => shape.id !== clicked.id)
saveToHistory(newShapes)
setSelectedShape(null)
}
}
// Escape to deselect
else if (e.key === 'Escape') {
e.preventDefault()
setSelectedShape(null)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedShape, shapes, propagators])
// Helper function to get the center of a shape // Helper function to get the center of a shape
const getShapeCenter = (shape: Shape): { x: number; y: number } => { const getShapeCenter = (shape: Shape): { x: number; y: number } => {
if (shape.width && shape.height) { if (shape.width && shape.height) {
@ -448,7 +554,8 @@ export default function ItalismPage() {
return next return next
}) })
} }
setShapes(shapes.filter((shape) => shape.id !== clicked.id)) const newShapes = shapes.filter((shape) => shape.id !== clicked.id)
saveToHistory(newShapes)
setSelectedShape(null) setSelectedShape(null)
} }
} else if (tool === "text") { } else if (tool === "text") {
@ -463,7 +570,7 @@ export default function ItalismPage() {
text, text,
color: "#6366f1", color: "#6366f1",
} }
setShapes([...shapes, newShape]) saveToHistory([...shapes, newShape])
} }
} else if (tool === "arrow") { } else if (tool === "arrow") {
// Special handling for arrow tool - snap to shapes // Special handling for arrow tool - snap to shapes
@ -590,16 +697,20 @@ export default function ItalismPage() {
} }
// If it's an arrow with both source and target, create a propagator // If it's an arrow with both source and target, create a propagator
const newShapesArray = [...shapes, newShape]
if (newShape.type === "arrow" && newShape.sourceShapeId && newShape.targetShapeId) { if (newShape.type === "arrow" && newShape.sourceShapeId && newShape.targetShapeId) {
newShape.expression = "value: from.value" // Default expression newShape.expression = "value: from.value" // Default expression
setShapes([...shapes, newShape]) saveToHistory(newShapesArray)
// Create propagator for this arrow // Create propagator for this arrow
setTimeout(() => createPropagatorForArrow(newShape), 0) setTimeout(() => createPropagatorForArrow(newShape), 0)
} else { } else {
setShapes([...shapes, newShape]) saveToHistory(newShapesArray)
} }
setCurrentShape(null) setCurrentShape(null)
} else if (isDragging) {
// Save to history when dragging stops
saveToHistory(shapes)
} }
setIsDrawing(false) setIsDrawing(false)
setIsDragging(false) setIsDragging(false)
@ -733,6 +844,10 @@ export default function ItalismPage() {
), ),
) )
}} }}
onBlur={() => {
// Save to history when user finishes editing
saveToHistory(shapes)
}}
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs" className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
placeholder="value: from.value * 2" placeholder="value: from.value * 2"
/> />
@ -792,6 +907,10 @@ export default function ItalismPage() {
), ),
) )
}} }}
onBlur={() => {
// Save to history when user finishes editing
saveToHistory(shapes)
}}
className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs" className="w-full px-2 py-1 bg-slate-800 text-white rounded text-xs"
/> />
</div> </div>
@ -806,6 +925,24 @@ export default function ItalismPage() {
{/* Actions */} {/* Actions */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex gap-2">
<button
onClick={undo}
disabled={historyIndexRef.current <= 0}
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-600 disabled:cursor-not-allowed rounded text-sm transition-colors"
title="Undo (Ctrl+Z)"
>
Undo
</button>
<button
onClick={redo}
disabled={historyIndexRef.current >= historyRef.current.length - 1}
className="flex-1 px-4 py-2 bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800 disabled:text-slate-600 disabled:cursor-not-allowed rounded text-sm transition-colors"
title="Redo (Ctrl+Shift+Z)"
>
Redo
</button>
</div>
<button <button
onClick={toggleFullscreen} onClick={toggleFullscreen}
className="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm transition-colors" className="w-full px-4 py-2 bg-slate-700 hover:bg-slate-600 rounded text-sm transition-colors"
@ -813,7 +950,7 @@ export default function ItalismPage() {
{isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"} {isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
</button> </button>
<button <button
onClick={() => setShapes([])} onClick={() => saveToHistory([])}
className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors" className="w-full px-4 py-2 bg-red-600 hover:bg-red-700 rounded text-sm transition-colors"
> >
Clear Canvas Clear Canvas