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:
@@ -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">
|
||||||
|
<div className="canvas-header">
|
||||||
|
<div>
|
||||||
<h1 className="app-title">Apparel Designer</h1>
|
<h1 className="app-title">Apparel Designer</h1>
|
||||||
<p className="app-subtitle">T-shirt customization editor</p>
|
<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}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user