Skip to content

Commit 06f20a4

Browse files
committed
Add undo/redo feature
1 parent f07d60c commit 06f20a4

File tree

16 files changed

+515
-3
lines changed

16 files changed

+515
-3
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ This repo contains an example project of a image manipulation web application. T
88

99
This application makes use of TypeScript, ReactJS, Redux and Redux Saga.
1010

11+
## Features
12+
13+
### Image Manipulation
14+
- Upload and display images
15+
- Rotate images
16+
- Apply filters like sepia and black-and-white
17+
18+
### Undo/Redo Functionality
19+
- Track editing history with Redux state
20+
- Undo/Redo buttons in the UI
21+
- Keyboard shortcuts:
22+
- Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
23+
- Redo: Ctrl+Shift+Z or Ctrl+Y (Windows/Linux) or Cmd+Shift+Z or Cmd+Y (Mac)
24+
- History state is cleared when a new image is uploaded
25+
1126
## Running this project
1227

1328
// TODO: add the run instructions

photo-editor/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ import './App.css';
44
import PhotoUploader from './components/PhotoUploader';
55
import PhotoControls from './components/PhotoControls';
66
import ImageRenderer from './components/ImageRenderer';
7+
import UndoRedoControls from './components/UndoRedoControls';
78
import { loadSavedPhoto } from './store/photo/actions';
89
import { AppDispatch } from './store';
10+
import { useUndoRedoKeyboardShortcuts } from './hooks/useUndoRedoKeyboardShortcuts';
911

1012
function App() {
1113
const dispatch = useDispatch<AppDispatch>();
14+
useUndoRedoKeyboardShortcuts();
1215

1316
useEffect(() => {
1417
// When the app loads, try to load any saved photo state
@@ -21,6 +24,7 @@ function App() {
2124
<h1>Photo Editor</h1>
2225
<p>Upload a photo to get started</p>
2326
<PhotoUploader />
27+
<UndoRedoControls />
2428
<ImageRenderer />
2529
<PhotoControls />
2630
</header>
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
.undo-redo-controls {
2+
display: flex;
3+
justify-content: space-between;
4+
margin: 10px 0;
5+
gap: 10px;
6+
}
7+
8+
.undo-button, .redo-button {
9+
padding: 8px 16px;
10+
background-color: #f0f0f0;
11+
border: 1px solid #ccc;
12+
border-radius: 4px;
13+
cursor: pointer;
14+
font-size: 14px;
15+
transition: all 0.2s ease;
16+
min-width: 100px;
17+
display: flex;
18+
align-items: center;
19+
justify-content: center;
20+
}
21+
22+
.undo-button:hover, .redo-button:hover {
23+
background-color: #e0e0e0;
24+
}
25+
26+
.undo-button:disabled, .redo-button:disabled {
27+
opacity: 0.5;
28+
cursor: not-allowed;
29+
}
30+
31+
.undo-button:active, .redo-button:active {
32+
background-color: #d0d0d0;
33+
transform: scale(0.98);
34+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from 'react';
2+
import { useDispatch, useSelector } from 'react-redux';
3+
import { undo, redo } from '../store/history/actions';
4+
import { selectCanUndo, selectCanRedo, selectPast, selectFuture } from '../store/history/selectors';
5+
import './UndoRedoControls.css';
6+
7+
const UndoRedoControls: React.FC = () => {
8+
const dispatch = useDispatch();
9+
const canUndo = useSelector(selectCanUndo);
10+
const canRedo = useSelector(selectCanRedo);
11+
const past = useSelector(selectPast);
12+
const future = useSelector(selectFuture);
13+
14+
const handleUndo = () => {
15+
dispatch(undo());
16+
};
17+
18+
const handleRedo = () => {
19+
dispatch(redo());
20+
};
21+
22+
return (
23+
<div className="undo-redo-controls">
24+
<button
25+
className="undo-button"
26+
onClick={handleUndo}
27+
disabled={!canUndo}
28+
title={`Undo (${past.length} ${past.length === 1 ? 'step' : 'steps'} available)`}
29+
>
30+
↩ Undo {canUndo ? `(${past.length})` : ''}
31+
</button>
32+
<button
33+
className="redo-button"
34+
onClick={handleRedo}
35+
disabled={!canRedo}
36+
title={`Redo (${future.length} ${future.length === 1 ? 'step' : 'steps'} available)`}
37+
>
38+
Redo {canRedo ? `(${future.length})` : ''}
39+
</button>
40+
</div>
41+
);
42+
};
43+
44+
export default UndoRedoControls;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useEffect } from 'react';
2+
import { useDispatch, useSelector } from 'react-redux';
3+
import { undo, redo } from '../store/history/actions';
4+
import { selectCanUndo, selectCanRedo } from '../store/history/selectors';
5+
6+
export const useUndoRedoKeyboardShortcuts = () => {
7+
const dispatch = useDispatch();
8+
const canUndo = useSelector(selectCanUndo);
9+
const canRedo = useSelector(selectCanRedo);
10+
11+
useEffect(() => {
12+
const handleKeyDown = (e: KeyboardEvent) => {
13+
// Detect Ctrl+Z or Cmd+Z (for Mac) for Undo
14+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey && canUndo) {
15+
e.preventDefault();
16+
dispatch(undo());
17+
}
18+
19+
// Detect Ctrl+Shift+Z or Cmd+Shift+Z (for Mac) for Redo
20+
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey && canRedo) {
21+
e.preventDefault();
22+
dispatch(redo());
23+
}
24+
25+
// Alternative Redo shortcut: Ctrl+Y or Cmd+Y
26+
if ((e.ctrlKey || e.metaKey) && e.key === 'y' && canRedo) {
27+
e.preventDefault();
28+
dispatch(redo());
29+
}
30+
};
31+
32+
document.addEventListener('keydown', handleKeyDown);
33+
34+
return () => {
35+
document.removeEventListener('keydown', handleKeyDown);
36+
};
37+
}, [dispatch, canUndo, canRedo]);
38+
};
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {
2+
UNDO,
3+
REDO,
4+
CLEAR_HISTORY,
5+
RECORD_HISTORY,
6+
UndoAction,
7+
RedoAction,
8+
ClearHistoryAction,
9+
RecordHistoryAction
10+
} from './types';
11+
import { PhotoState } from '../photo/types';
12+
13+
export const undo = (): UndoAction => ({
14+
type: UNDO
15+
});
16+
17+
export const redo = (): RedoAction => ({
18+
type: REDO
19+
});
20+
21+
export const clearHistory = (): ClearHistoryAction => ({
22+
type: CLEAR_HISTORY
23+
});
24+
25+
export const recordHistory = (state: PhotoState): RecordHistoryAction => ({
26+
type: RECORD_HISTORY,
27+
payload: state
28+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
HistoryState,
3+
HistoryActionTypes,
4+
UNDO,
5+
REDO,
6+
CLEAR_HISTORY,
7+
RECORD_HISTORY
8+
} from './types';
9+
10+
const HISTORY_LIMIT = 20;
11+
12+
const initialState: HistoryState = {
13+
past: [],
14+
future: [],
15+
limit: HISTORY_LIMIT
16+
};
17+
18+
const historyReducer = (
19+
state = initialState,
20+
action: HistoryActionTypes
21+
): HistoryState => {
22+
switch (action.type) {
23+
case RECORD_HISTORY:
24+
console.log('Recording history:', action.payload, state);
25+
return {
26+
...state,
27+
past: [
28+
...state.past.slice(-state.limit + 1),
29+
action.payload
30+
],
31+
future: []
32+
};
33+
34+
case UNDO: {
35+
if (state.past.length === 0) return state;
36+
37+
const newPast = state.past.slice(0, state.past.length - 1);
38+
const lastState = state.past[state.past.length - 1];
39+
40+
return {
41+
...state,
42+
past: newPast,
43+
future: [lastState, ...state.future]
44+
};
45+
}
46+
47+
case REDO: {
48+
if (state.future.length === 0) return state;
49+
50+
const nextState = state.future[0];
51+
const newFuture = state.future.slice(1);
52+
53+
return {
54+
...state,
55+
past: [...state.past, nextState],
56+
future: newFuture
57+
};
58+
}
59+
60+
case CLEAR_HISTORY:
61+
return initialState;
62+
63+
default:
64+
return state;
65+
}
66+
};
67+
68+
export default historyReducer;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { takeLatest, takeEvery, select, put, all, call } from 'redux-saga/effects';
2+
import {
3+
UNDO,
4+
REDO
5+
} from './types';
6+
import { selectPast, selectFuture } from './selectors';
7+
import { setPhoto } from '../photo/actions';
8+
import { PhotoState } from '../photo/types';
9+
import { recordHistory } from './actions';
10+
import {
11+
SET_PHOTO,
12+
RESET_ROTATION,
13+
ROTATE_PHOTO,
14+
PAN_ZOOM_PHOTO,
15+
ADD_FILTER,
16+
REMOVE_FILTER,
17+
PhotoActionTypes
18+
} from '../photo/types';
19+
20+
function* undoSaga() {
21+
const past: PhotoState[] = yield select(selectPast);
22+
23+
console.log(past);
24+
if (past.length > 0) {
25+
const previousState = past[past.length - 1];
26+
27+
if (previousState.photo) {
28+
// Set recordInHistory to false to prevent creating a new history entry
29+
yield put(setPhoto(previousState.photo, false));
30+
}
31+
}
32+
}
33+
34+
function* redoSaga() {
35+
const future: PhotoState[] = yield select(selectFuture);
36+
37+
if (future.length > 0) {
38+
const nextState = future[0];
39+
40+
if (nextState.photo) {
41+
// Set recordInHistory to false to prevent creating a new history entry
42+
yield put(setPhoto(nextState.photo, false));
43+
}
44+
}
45+
}
46+
47+
function* recordHistorySaga(action: PhotoActionTypes) {
48+
// Skip recording if this action is from undo/redo operations
49+
if (action.type === SET_PHOTO && action.meta && action.meta.recordInHistory === false) {
50+
return;
51+
}
52+
53+
const currentPhotoState: PhotoState = yield select(state => ({
54+
photo: state.photo.photo
55+
}));
56+
57+
if (currentPhotoState.photo) {
58+
yield put(recordHistory(currentPhotoState));
59+
}
60+
}
61+
62+
function* watchHistoryActions() {
63+
yield takeLatest(UNDO, undoSaga);
64+
yield takeLatest(REDO, redoSaga);
65+
}
66+
67+
function* watchPhotoActionsForHistory() {
68+
yield takeEvery(
69+
[
70+
SET_PHOTO,
71+
RESET_ROTATION,
72+
ROTATE_PHOTO,
73+
PAN_ZOOM_PHOTO,
74+
ADD_FILTER,
75+
REMOVE_FILTER
76+
],
77+
recordHistorySaga
78+
);
79+
}
80+
81+
export default function* historySagas() {
82+
yield all([
83+
watchHistoryActions(),
84+
watchPhotoActionsForHistory()
85+
]);
86+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { RootState } from '../index';
2+
3+
export const selectHistory = (state: RootState) => state.history;
4+
export const selectPast = (state: RootState) => state.history.past;
5+
export const selectFuture = (state: RootState) => state.history.future;
6+
export const selectCanUndo = (state: RootState) => state.history.past.length > 0;
7+
export const selectCanRedo = (state: RootState) => state.history.future.length > 0;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { PhotoState } from '../photo/types';
2+
3+
export interface HistoryState {
4+
past: PhotoState[];
5+
future: PhotoState[];
6+
limit: number;
7+
}
8+
9+
export const UNDO = 'history/UNDO';
10+
export const REDO = 'history/REDO';
11+
export const CLEAR_HISTORY = 'history/CLEAR_HISTORY';
12+
export const RECORD_HISTORY = 'history/RECORD_HISTORY';
13+
14+
export interface UndoAction {
15+
type: typeof UNDO;
16+
[key: string]: any;
17+
}
18+
19+
export interface RedoAction {
20+
type: typeof REDO;
21+
[key: string]: any;
22+
}
23+
24+
export interface ClearHistoryAction {
25+
type: typeof CLEAR_HISTORY;
26+
[key: string]: any;
27+
}
28+
29+
export interface RecordHistoryAction {
30+
type: typeof RECORD_HISTORY;
31+
payload: PhotoState;
32+
[key: string]: any;
33+
}
34+
35+
export type HistoryActionTypes =
36+
| UndoAction
37+
| RedoAction
38+
| ClearHistoryAction
39+
| RecordHistoryAction;

0 commit comments

Comments
 (0)