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 @@
padding: 3px;
}
$border-radius: 0.25rem;
.button {
height: 2rem;
padding: 0.25rem;
outline: none;
background: white;
border-radius: 0.25rem;
border-radius: $border-radius;
border: 1px solid #ddd;
cursor: pointer;
font-size: 0.85rem;
......@@ -83,3 +85,27 @@
width: 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';
import playIcon from '../record-modal/icon--play.svg';
import stopIcon from '../record-modal/icon--stop-playback.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 higherIcon from './icon--higher.svg';
import lowerIcon from './icon--lower.svg';
......@@ -52,6 +54,16 @@ const messages = defineMessages({
description: 'Title of the button to save trimmed sound',
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: {
id: 'soundEditor.faster',
description: 'Title of the button to apply the faster effect',
......@@ -140,6 +152,24 @@ const SoundEditor = props => (
<FormattedMessage {...messages.save} />
)}
</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 className={styles.row}>
......@@ -206,6 +236,8 @@ const SoundEditor = props => (
);
SoundEditor.propTypes = {
canRedo: PropTypes.bool.isRequired,
canUndo: PropTypes.bool.isRequired,
chunkLevels: PropTypes.arrayOf(PropTypes.number).isRequired,
intl: intlShape,
name: PropTypes.string.isRequired,
......@@ -215,6 +247,7 @@ SoundEditor.propTypes = {
onFaster: PropTypes.func.isRequired,
onLouder: PropTypes.func.isRequired,
onPlay: PropTypes.func.isRequired,
onRedo: PropTypes.func.isRequired,
onReverse: PropTypes.func.isRequired,
onRobot: PropTypes.func.isRequired,
onSetTrimEnd: PropTypes.func,
......@@ -222,6 +255,7 @@ SoundEditor.propTypes = {
onSlower: PropTypes.func.isRequired,
onSofter: PropTypes.func.isRequired,
onStop: PropTypes.func.isRequired,
onUndo: PropTypes.func.isRequired,
playhead: PropTypes.number,
trimEnd: PropTypes.number,
trimStart: PropTypes.number
......
......@@ -21,7 +21,10 @@ class SoundEditor extends React.Component {
'handleActivateTrim',
'handleUpdateTrimEnd',
'handleUpdateTrimStart',
'handleEffect'
'handleEffect',
'handleUndo',
'handleRedo',
'submitNewSamples'
]);
this.state = {
chunkLevels: computeChunkedRMS(this.props.samples),
......@@ -29,12 +32,17 @@ class SoundEditor extends React.Component {
trimStart: null,
trimEnd: null
};
this.redoStack = [];
this.undoStack = [];
}
componentDidMount () {
this.audioBufferPlayer = new AudioBufferPlayer(this.props.samples, this.props.sampleRate);
}
componentWillReceiveProps (newProps) {
if (newProps.soundId !== this.props.soundId) { // A different sound has been selected
this.redoStack = [];
this.undoStack = [];
this.resetState(newProps.samples, newProps.sampleRate);
}
}
......@@ -51,7 +59,11 @@ class SoundEditor extends React.Component {
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.props.onUpdateSoundBuffer(
this.props.soundIndex,
......@@ -101,9 +113,25 @@ class SoundEditor extends React.Component {
handleEffect (/* name */) {
// @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 () {
return (
<SoundEditorComponent
canRedo={this.redoStack.length > 0}
canUndo={this.undoStack.length > 0}
chunkLevels={this.state.chunkLevels}
name={this.props.name}
playhead={this.state.playhead}
......@@ -115,6 +143,7 @@ class SoundEditor extends React.Component {
onFaster={this.effectFactory('faster')}
onLouder={this.effectFactory('louder')}
onPlay={this.handlePlay}
onRedo={this.handleRedo}
onReverse={this.effectFactory('reverse')}
onRobot={this.effectFactory('robot')}
onSetTrimEnd={this.handleUpdateTrimEnd}
......@@ -122,6 +151,7 @@ class SoundEditor extends React.Component {
onSlower={this.effectFactory('slower')}
onSofter={this.effectFactory('softer')}
onStop={this.handleStopPlaying}
onUndo={this.handleUndo}
/>
);
}
......
......@@ -58,6 +58,30 @@ exports[`Sound Editor Component matches snapshot 1`] = `
Save
</span>
</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
......
......@@ -7,6 +7,8 @@ describe('Sound Editor Component', () => {
let props;
beforeEach(() => {
props = {
canUndo: true,
canRedo: false,
chunkLevels: [1, 2, 3],
name: 'sound name',
playhead: 0.5,
......@@ -15,6 +17,7 @@ describe('Sound Editor Component', () => {
onActivateTrim: jest.fn(),
onChangeName: jest.fn(),
onPlay: jest.fn(),
onRedo: jest.fn(),
onReverse: jest.fn(),
onSofter: jest.fn(),
onLouder: jest.fn(),
......@@ -24,7 +27,8 @@ describe('Sound Editor Component', () => {
onSlower: jest.fn(),
onSetTrimEnd: jest.fn(),
onSetTrimStart: jest.fn(),
onStop: jest.fn()
onStop: jest.fn(),
onUndo: jest.fn()
};
});
......@@ -64,6 +68,7 @@ describe('Sound Editor Component', () => {
.simulate('blur');
expect(props.onChangeName).toHaveBeenCalled();
});
test('effect buttons call the correct callbacks', () => {
const wrapper = mountWithIntl(<SoundEditor {...props} />);
......@@ -88,4 +93,23 @@ describe('Sound Editor Component', () => {
wrapper.find('[children="Softer"]').simulate('click');
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', () => {
component.props().onChangeName('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