Skip to content
Snippets Groups Projects
Commit a1c9c203 authored by Paul Kaplan's avatar Paul Kaplan
Browse files

Add undo/redo functionality and tests

parent a6105074
No related branches found
No related tags found
No related merge requests found
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
...@@ -35,12 +35,14 @@ ...@@ -35,12 +35,14 @@
padding: 3px; padding: 3px;
} }
$border-radius: 0.25rem;
.button { .button {
height: 2rem; height: 2rem;
padding: 0.25rem; padding: 0.25rem;
outline: none; outline: none;
background: white; background: white;
border-radius: 0.25rem; border-radius: $border-radius;
border: 1px solid #ddd; border: 1px solid #ddd;
cursor: pointer; cursor: pointer;
font-size: 0.85rem; font-size: 0.85rem;
...@@ -83,3 +85,27 @@ ...@@ -83,3 +85,27 @@
width: 60px; width: 60px;
height: 60px; height: 60px;
} }
.button-group {
margin: 0 1rem;
}
.button-group .button {
border-radius: 0;
border-left: none;
}
.button-group .button:last-of-type {
border-top-right-radius: $border-radius;
border-bottom-right-radius: $border-radius;
}
.button-group .button:first-of-type {
border-left: 1px solid #ddd;
border-top-left-radius: $border-radius;
border-bottom-left-radius: $border-radius;
}
.button:disabled > img {
opacity: 0.25;
}
...@@ -16,6 +16,8 @@ import styles from './sound-editor.css'; ...@@ -16,6 +16,8 @@ import styles from './sound-editor.css';
import playIcon from '../record-modal/icon--play.svg'; import playIcon from '../record-modal/icon--play.svg';
import stopIcon from '../record-modal/icon--stop-playback.svg'; import stopIcon from '../record-modal/icon--stop-playback.svg';
import trimIcon from './icon--trim.svg'; import trimIcon from './icon--trim.svg';
import redoIcon from './icon--redo.svg';
import undoIcon from './icon--undo.svg';
import echoIcon from './icon--echo.svg'; import echoIcon from './icon--echo.svg';
import higherIcon from './icon--higher.svg'; import higherIcon from './icon--higher.svg';
import lowerIcon from './icon--lower.svg'; import lowerIcon from './icon--lower.svg';
...@@ -52,6 +54,16 @@ const messages = defineMessages({ ...@@ -52,6 +54,16 @@ const messages = defineMessages({
description: 'Title of the button to save trimmed sound', description: 'Title of the button to save trimmed sound',
defaultMessage: 'Save' defaultMessage: 'Save'
}, },
undo: {
id: 'soundEditor.undo',
description: 'Title of the button to undo',
defaultMessage: 'Undo'
},
redo: {
id: 'soundEditor.redo',
description: 'Title of the button to redo',
defaultMessage: 'Redo'
},
faster: { faster: {
id: 'soundEditor.faster', id: 'soundEditor.faster',
description: 'Title of the button to apply the faster effect', description: 'Title of the button to apply the faster effect',
...@@ -140,6 +152,24 @@ const SoundEditor = props => ( ...@@ -140,6 +152,24 @@ const SoundEditor = props => (
<FormattedMessage {...messages.save} /> <FormattedMessage {...messages.save} />
)} )}
</button> </button>
<div className={styles.buttonGroup}>
<button
className={styles.button}
disabled={!props.canUndo}
title={props.intl.formatMessage(messages.undo)}
onClick={props.onUndo}
>
<img src={undoIcon} />
</button>
<button
className={styles.button}
disabled={!props.canRedo}
title={props.intl.formatMessage(messages.redo)}
onClick={props.onRedo}
>
<img src={redoIcon} />
</button>
</div>
</div> </div>
</div> </div>
<div className={styles.row}> <div className={styles.row}>
...@@ -206,6 +236,8 @@ const SoundEditor = props => ( ...@@ -206,6 +236,8 @@ const SoundEditor = props => (
); );
SoundEditor.propTypes = { SoundEditor.propTypes = {
canRedo: PropTypes.bool.isRequired,
canUndo: PropTypes.bool.isRequired,
chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired, chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
intl: intlShape, intl: intlShape,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
...@@ -215,6 +247,7 @@ SoundEditor.propTypes = { ...@@ -215,6 +247,7 @@ SoundEditor.propTypes = {
onFaster: PropTypes.func.isRequired, onFaster: PropTypes.func.isRequired,
onLouder: PropTypes.func.isRequired, onLouder: PropTypes.func.isRequired,
onPlay: PropTypes.func.isRequired, onPlay: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
onReverse: PropTypes.func.isRequired, onReverse: PropTypes.func.isRequired,
onRobot: PropTypes.func.isRequired, onRobot: PropTypes.func.isRequired,
onSetTrimEnd: PropTypes.func, onSetTrimEnd: PropTypes.func,
...@@ -222,6 +255,7 @@ SoundEditor.propTypes = { ...@@ -222,6 +255,7 @@ SoundEditor.propTypes = {
onSlower: PropTypes.func.isRequired, onSlower: PropTypes.func.isRequired,
onSofter: PropTypes.func.isRequired, onSofter: PropTypes.func.isRequired,
onStop: PropTypes.func.isRequired, onStop: PropTypes.func.isRequired,
onUndo: PropTypes.func.isRequired,
playhead: PropTypes.number, playhead: PropTypes.number,
trimEnd: PropTypes.number, trimEnd: PropTypes.number,
trimStart: PropTypes.number trimStart: PropTypes.number
......
...@@ -21,7 +21,10 @@ class SoundEditor extends React.Component { ...@@ -21,7 +21,10 @@ class SoundEditor extends React.Component {
'handleActivateTrim', 'handleActivateTrim',
'handleUpdateTrimEnd', 'handleUpdateTrimEnd',
'handleUpdateTrimStart', 'handleUpdateTrimStart',
'handleEffect' 'handleEffect',
'handleUndo',
'handleRedo',
'submitNewSamples'
]); ]);
this.state = { this.state = {
chunkLevels: computeChunkedRMS(this.props.samples), chunkLevels: computeChunkedRMS(this.props.samples),
...@@ -29,12 +32,17 @@ class SoundEditor extends React.Component { ...@@ -29,12 +32,17 @@ class SoundEditor extends React.Component {
trimStart: null, trimStart: null,
trimEnd: null trimEnd: null
}; };
this.redoStack = [];
this.undoStack = [];
} }
componentDidMount () { componentDidMount () {
this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate); this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
} }
componentWillReceiveProps (newProps) { componentWillReceiveProps (newProps) {
if (newProps.soundId !== this.props.soundId) { // A different sound has been selected if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
this.redoStack = [];
this.undoStack = [];
this.resetState(newProps.samples, newProps.sampleRate); this.resetState(newProps.samples, newProps.sampleRate);
} }
} }
...@@ -51,7 +59,11 @@ class SoundEditor extends React.Component { ...@@ -51,7 +59,11 @@ class SoundEditor extends React.Component {
trimEnd: null trimEnd: null
}); });
} }
submitNewSamples (samples, sampleRate) { submitNewSamples (samples, sampleRate, skipUndo) {
if (!skipUndo) {
this.redoStack = [];
this.undoStack.push(this.props.samples.slice(0));
}
this.resetState(samples, sampleRate); this.resetState(samples, sampleRate);
this.props.onUpdateSoundBuffer( this.props.onUpdateSoundBuffer(
this.props.soundIndex, this.props.soundIndex,
...@@ -101,9 +113,25 @@ class SoundEditor extends React.Component { ...@@ -101,9 +113,25 @@ class SoundEditor extends React.Component {
handleEffect (/* name */) { handleEffect (/* name */) {
// @todo implement effects // @todo implement effects
} }
handleUndo () {
this.redoStack.push(this.props.samples.slice(0));
const samples = this.undoStack.pop();
if (samples) {
this.submitNewSamples(samples, this.props.sampleRate, true);
}
}
handleRedo () {
const samples = this.redoStack.pop();
if (samples) {
this.undoStack.push(this.props.samples.slice(0));
this.submitNewSamples(samples, this.props.sampleRate, true);
}
}
render () { render () {
return ( return (
<SoundEditorComponent <SoundEditorComponent
canRedo={this.redoStack.length > 0}
canUndo={this.undoStack.length > 0}
chunkLevels={this.state.chunkLevels} chunkLevels={this.state.chunkLevels}
name={this.props.name} name={this.props.name}
playhead={this.state.playhead} playhead={this.state.playhead}
...@@ -115,6 +143,7 @@ class SoundEditor extends React.Component { ...@@ -115,6 +143,7 @@ class SoundEditor extends React.Component {
onFaster={this.effectFactory('faster')} onFaster={this.effectFactory('faster')}
onLouder={this.effectFactory('louder')} onLouder={this.effectFactory('louder')}
onPlay={this.handlePlay} onPlay={this.handlePlay}
onRedo={this.handleRedo}
onReverse={this.effectFactory('reverse')} onReverse={this.effectFactory('reverse')}
onRobot={this.effectFactory('robot')} onRobot={this.effectFactory('robot')}
onSetTrimEnd={this.handleUpdateTrimEnd} onSetTrimEnd={this.handleUpdateTrimEnd}
...@@ -122,6 +151,7 @@ class SoundEditor extends React.Component { ...@@ -122,6 +151,7 @@ class SoundEditor extends React.Component {
onSlower={this.effectFactory('slower')} onSlower={this.effectFactory('slower')}
onSofter={this.effectFactory('softer')} onSofter={this.effectFactory('softer')}
onStop={this.handleStopPlaying} onStop={this.handleStopPlaying}
onUndo={this.handleUndo}
/> />
); );
} }
......
...@@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = ` ...@@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = `
Save Save
</span> </span>
</button> </button>
<div
className={undefined}
>
<button
className={undefined}
disabled={false}
onClick={[Function]}
title="Undo"
>
<img
src="test-file-stub"
/>
</button>
<button
className={undefined}
disabled={true}
onClick={[Function]}
title="Redo"
>
<img
src="test-file-stub"
/>
</button>
</div>
</div> </div>
</div> </div>
<div <div
......
...@@ -7,6 +7,8 @@ describe('Sound Editor Component', () => { ...@@ -7,6 +7,8 @@ describe('Sound Editor Component', () => {
let props; let props;
beforeEach(() => { beforeEach(() => {
props = { props = {
canUndo: true,
canRedo: false,
chunkLevels: [1, 2, 3], chunkLevels: [1, 2, 3],
name: 'sound name', name: 'sound name',
playhead: 0.5, playhead: 0.5,
...@@ -15,6 +17,7 @@ describe('Sound Editor Component', () => { ...@@ -15,6 +17,7 @@ describe('Sound Editor Component', () => {
onActivateTrim: jest.fn(), onActivateTrim: jest.fn(),
onChangeName: jest.fn(), onChangeName: jest.fn(),
onPlay: jest.fn(), onPlay: jest.fn(),
onRedo: jest.fn(),
onReverse: jest.fn(), onReverse: jest.fn(),
onSofter: jest.fn(), onSofter: jest.fn(),
onLouder: jest.fn(), onLouder: jest.fn(),
...@@ -24,7 +27,8 @@ describe('Sound Editor Component', () => { ...@@ -24,7 +27,8 @@ describe('Sound Editor Component', () => {
onSlower: jest.fn(), onSlower: jest.fn(),
onSetTrimEnd: jest.fn(), onSetTrimEnd: jest.fn(),
onSetTrimStart: jest.fn(), onSetTrimStart: jest.fn(),
onStop: jest.fn() onStop: jest.fn(),
onUndo: jest.fn()
}; };
}); });
...@@ -64,6 +68,7 @@ describe('Sound Editor Component', () => { ...@@ -64,6 +68,7 @@ describe('Sound Editor Component', () => {
.simulate('blur'); .simulate('blur');
expect(props.onChangeName).toHaveBeenCalled(); expect(props.onChangeName).toHaveBeenCalled();
}); });
test('effect buttons call the correct callbacks', () => { test('effect buttons call the correct callbacks', () => {
const wrapper = mountWithIntl(<SoundEditor {...props} />); const wrapper = mountWithIntl(<SoundEditor {...props} />);
...@@ -88,4 +93,23 @@ describe('Sound Editor Component', () => { ...@@ -88,4 +93,23 @@ describe('Sound Editor Component', () => {
wrapper.find('[children="Softer"]').simulate('click'); wrapper.find('[children="Softer"]').simulate('click');
expect(props.onSofter).toHaveBeenCalled(); expect(props.onSofter).toHaveBeenCalled();
}); });
test('undo and redo buttons can be disabled by canUndo/canRedo', () => {
let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={false} />);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true);
wrapper = mountWithIntl(<SoundEditor {...props} canUndo={false} canRedo={true} />);
expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true);
expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false);
});
test.skip('undo/redo buttons call the correct callback', () => {
let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={true} />);
wrapper.find('button[title="Undo"]').simulate('click');
expect(props.onUndo).toHaveBeenCalled();
wrapper.find('button[title="Redo"]').simulate('click');
expect(props.onRedo).toHaveBeenCalled();
});
}); });
...@@ -89,4 +89,39 @@ describe('Sound Editor Container', () => { ...@@ -89,4 +89,39 @@ describe('Sound Editor Container', () => {
component.props().onChangeName('hello'); component.props().onChangeName('hello');
expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello'); expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello');
}); });
test('undo/redo functionality', () => {
const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />);
const component = wrapper.find(SoundEditorComponent);
// Undo and redo should be disabled initially
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(false);
// Submitting new samples should make it possible to undo
component.props().onActivateTrim(); // Activate trimming
component.props().onActivateTrim(); // Submit new samples by calling again
wrapper.update();
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// Undoing should make it possible to redo and not possible to undo again
component.props().onUndo();
wrapper.update();
expect(component.prop('canUndo')).toEqual(false);
expect(component.prop('canRedo')).toEqual(true);
// Redoing should make it possible to undo and not possible to redo again
component.props().onRedo();
wrapper.update();
expect(component.prop('canUndo')).toEqual(true);
expect(component.prop('canRedo')).toEqual(false);
// New submission should clear the redo stack
component.props().onUndo(); // Undo to go back to a state where redo is enabled
wrapper.update();
expect(component.prop('canRedo')).toEqual(true);
component.props().onActivateTrim(); // Activate trimming
component.props().onActivateTrim(); // Submit new samples by calling again
expect(component.prop('canRedo')).toEqual(false);
});
}); });
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment