diff --git a/src/components/sound-editor/icon--redo.svg b/src/components/sound-editor/icon--redo.svg new file mode 100644 index 0000000000000000000000000000000000000000..960785c7b6707bda17c4b2365f3b08abda42cd7c Binary files /dev/null and b/src/components/sound-editor/icon--redo.svg differ diff --git a/src/components/sound-editor/icon--undo.svg b/src/components/sound-editor/icon--undo.svg new file mode 100644 index 0000000000000000000000000000000000000000..af3d7ba4239d630cdc6c5a21344887285354b8bc Binary files /dev/null and b/src/components/sound-editor/icon--undo.svg differ diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index c209d507a64955fd60733301458a008af5609f00..88fb5773f14dc0589be7a2796f5a3d9b6a900fe5 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -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; +} diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index 240e427194aba238e7eddd5f5c254307046ff860..21c2613bfa21511fc4e0aa0f3f1e616c0efe41e5 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -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 diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 6f3bb484135379d973d7ad3f1ec38a124d3fa4da..7170b645a24e8840c5bae4f51351819a64694c7f 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -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} /> ); } diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap index 076dd860b6b9a08d9b5536f93739a274947526a2..644d330d0e5a099f59cfe4c527d93b5aa5913535 100644 --- a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap +++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap @@ -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 diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx index b3b744235e827ff35654dbe4150b6647e4888189..6c12f473060ed5b7167fe2b353563345b349c92d 100644 --- a/test/unit/components/sound-editor.test.jsx +++ b/test/unit/components/sound-editor.test.jsx @@ -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(); + }); }); diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx index cca22eea98ff1350be1b04ad9b9aaa35bf6ef846..2d1920063c22f1c6020faac4356a99a0d3f9d461 100644 --- a/test/unit/containers/sound-editor.test.jsx +++ b/test/unit/containers/sound-editor.test.jsx @@ -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); + }); });