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:
parent
97a4eee6f0
commit
de07dabb36
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue