Phase 7: Undo/Redo

- History tracking with 50-state limit in useDesignEditor hook
- Undo: Ctrl/Cmd + Z keyboard shortcut
- Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
- Undo/Redo buttons in canvas header
- History saves state after add, update, delete, reorder operations
- Disabled button states when history is exhausted

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-21 01:27:51 -05:00
parent 72495fec3e
commit 537cfd572d
3 changed files with 178 additions and 15 deletions

View File

@@ -13,16 +13,45 @@ function App() {
deleteElement, deleteElement,
selectElement, selectElement,
deselectAll, deselectAll,
undo,
redo,
canUndo,
canRedo,
initializeHistory,
} = useDesignEditor(); } = useDesignEditor();
const selectedElement = elements.find((el) => el.id === selectedId); const selectedElement = elements.find((el) => el.id === selectedId);
// Keyboard shortcut: Delete/Backspace removes selected element // Initialize history on mount
useEffect(() => {
initializeHistory();
}, [initializeHistory]);
// Keyboard shortcuts
useEffect(() => { useEffect(() => {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return; return;
} }
// Undo: Ctrl/Cmd + Z
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) undo();
return;
}
// Redo: Ctrl/Cmd + Shift + Z or Ctrl/Cmd + Y
if ((e.ctrlKey || e.metaKey) && (
(e.key === 'z' && e.shiftKey) ||
e.key === 'y'
)) {
e.preventDefault();
if (canRedo) redo();
return;
}
// Delete/Backspace removes selected element
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedId) { if (selectedId) {
deleteElement(selectedId); deleteElement(selectedId);
@@ -32,7 +61,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, deleteElement]); }, [selectedId, deleteElement, undo, redo, canUndo, canRedo]);
const handleUpload = (data) => { const handleUpload = (data) => {
if (data.preview?.url) { if (data.preview?.url) {
@@ -82,8 +111,30 @@ function App() {
{/* Center Canvas */} {/* Center Canvas */}
<main className="canvas-container"> <main className="canvas-container">
<h1 className="app-title">Apparel Designer</h1> <div className="canvas-header">
<p className="app-subtitle">T-shirt customization editor</p> <div>
<h1 className="app-title">Apparel Designer</h1>
<p className="app-subtitle">T-shirt customization editor</p>
</div>
<div className="undo-redo-buttons">
<button
className={`icon-btn ${!canUndo ? 'disabled' : ''}`}
onClick={undo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
Undo
</button>
<button
className={`icon-btn ${!canRedo ? 'disabled' : ''}`}
onClick={redo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
>
Redo
</button>
</div>
</div>
<div className="canvas-wrapper"> <div className="canvas-wrapper">
<DesignCanvas <DesignCanvas
elements={elements} elements={elements}

View File

@@ -1,31 +1,70 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef } from 'react';
const MAX_HISTORY = 50;
export function useDesignEditor() { export function useDesignEditor() {
const [elements, setElements] = useState([]); const [elements, setElements] = useState([]);
const [selectedId, setSelectedId] = useState(null); const [selectedId, setSelectedId] = useState(null);
// History for undo/redo
const historyRef = useRef([]);
const historyIndexRef = useRef(-1);
const saveToHistory = useCallback((newElements) => {
// Remove any future history if we're in the middle of the stack
if (historyIndexRef.current < historyRef.current.length - 1) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1);
}
// Add new state to history
historyRef.current.push(JSON.stringify(newElements));
// Limit history size
if (historyRef.current.length > MAX_HISTORY) {
historyRef.current.shift();
} else {
historyIndexRef.current++;
}
}, []);
const canUndo = historyIndexRef.current > 0;
const canRedo = historyIndexRef.current < historyRef.current.length - 1;
const addElement = useCallback((element) => { const addElement = useCallback((element) => {
const newElement = { const newElement = {
...element, ...element,
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}; };
setElements((prev) => [...prev, newElement]);
setElements((prev) => {
const newElements = [...prev, newElement];
saveToHistory(newElements);
return newElements;
});
setSelectedId(newElement.id); setSelectedId(newElement.id);
return newElement.id; return newElement.id;
}, []); }, [saveToHistory]);
const updateElement = useCallback((id, attrs) => { const updateElement = useCallback((id, attrs) => {
setElements((prev) => setElements((prev) => {
prev.map((el) => (el.id === id ? { ...el, ...attrs } : el)) const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
); saveToHistory(newElements);
}, []); return newElements;
});
}, [saveToHistory]);
const deleteElement = useCallback((id) => { const deleteElement = useCallback((id) => {
setElements((prev) => prev.filter((el) => el.id !== id)); setElements((prev) => {
const newElements = prev.filter((el) => el.id !== id);
saveToHistory(newElements);
return newElements;
});
if (selectedId === id) { if (selectedId === id) {
setSelectedId(null); setSelectedId(null);
} }
}, [selectedId]); }, [selectedId, saveToHistory]);
const selectElement = useCallback((id) => { const selectElement = useCallback((id) => {
setSelectedId(id); setSelectedId(id);
@@ -39,11 +78,38 @@ export function useDesignEditor() {
setElements((prev) => { setElements((prev) => {
const index = prev.findIndex((el) => el.id === id); const index = prev.findIndex((el) => el.id === id);
if (index === -1 || index === newOrder) return prev; if (index === -1 || index === newOrder) return prev;
const newElements = [...prev]; const newElements = [...prev];
const [removed] = newElements.splice(index, 1); const [removed] = newElements.splice(index, 1);
newElements.splice(newOrder, 0, removed); newElements.splice(newOrder, 0, removed);
saveToHistory(newElements);
return newElements; return newElements;
}); });
}, [saveToHistory]);
const undo = useCallback(() => {
if (historyIndexRef.current > 0) {
historyIndexRef.current--;
const prevState = JSON.parse(historyRef.current[historyIndexRef.current]);
setElements(prevState);
setSelectedId(null);
}
}, []);
const redo = useCallback(() => {
if (historyIndexRef.current < historyRef.current.length - 1) {
historyIndexRef.current++;
const nextState = JSON.parse(historyRef.current[historyIndexRef.current]);
setElements(nextState);
setSelectedId(null);
}
}, []);
// Initialize history with empty state
const initializeHistory = useCallback(() => {
historyRef.current = [JSON.stringify([])];
historyIndexRef.current = 0;
}, []); }, []);
return { return {
@@ -55,5 +121,10 @@ export function useDesignEditor() {
selectElement, selectElement,
deselectAll, deselectAll,
reorderElement, reorderElement,
undo,
redo,
canUndo,
canRedo,
initializeHistory,
}; };
} }

View File

@@ -102,16 +102,57 @@ input, textarea, select {
.app-title { .app-title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
margin: 0 0 0.5rem 0; margin: 0 0 0.25rem 0;
color: var(--text-primary); color: var(--text-primary);
} }
.app-subtitle { .app-subtitle {
color: var(--text-secondary); color: var(--text-secondary);
margin: 0 0 1.5rem 0; margin: 0;
font-size: 0.875rem; font-size: 0.875rem;
} }
.canvas-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
width: 100%;
max-width: 400px;
}
.undo-redo-buttons {
display: flex;
gap: 0.5rem;
}
.icon-btn {
padding: 0.5rem 0.75rem;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s;
color: var(--text-secondary);
}
.icon-btn:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.icon-btn.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.canvas-wrapper { .canvas-wrapper {
margin-bottom: 1rem; margin-bottom: 1rem;
} }