From a1c9c203dfd935664d725cb24332de9f920fa52f Mon Sep 17 00:00:00 2001 From: Paul Kaplan <pkaplan@media.mit.edu> Date: Sat, 5 Aug 2017 13:17:40 -0400 Subject: [PATCH] Add undo/redo functionality and tests --- src/components/sound-editor/icon--redo.svg | Bin 0 -> 2891 bytes src/components/sound-editor/icon--undo.svg | Bin 0 -> 2848 bytes src/components/sound-editor/sound-editor.css | 28 +++++++++++++- src/components/sound-editor/sound-editor.jsx | 34 +++++++++++++++++ src/containers/sound-editor.jsx | 34 ++++++++++++++++- .../__snapshots__/sound-editor.test.jsx.snap | 24 ++++++++++++ test/unit/components/sound-editor.test.jsx | 26 ++++++++++++- test/unit/containers/sound-editor.test.jsx | 35 ++++++++++++++++++ 8 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/components/sound-editor/icon--redo.svg create mode 100644 src/components/sound-editor/icon--undo.svg 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 GIT binary patch literal 2891 zcmZ{mTW=ay6oudOE2h(zSYi(6eu==5N|nC2Qq-hBfSQSoj2R22w)^k<?E~0Ya^}Ub zI9_}0wJ&F8pP%nH&11jYuZQitHA=Tlzr7w->+Q|F{rmfuF1O8oTy9s(&9Lp~?RIEC zUw)YFA8(qc^=iE3fBeI9+uZi+o7-{TX8H2C?w@`ep69J@bYpx2$5{x|HU!=7=k4t{ zK75)^pPru7lT*X)W@@$8Q^=1;K0R;N+q?G;no`QN{@PxCXn1D7bY1gDzwLL+aoBxo zzTNfX^=;#wvdu?VbSxK5*PKQCXSnU}*UMwF>Uy}J?hpUdbw|o^J#PBTUEl5c>tVN= zO%K2!-m2eUUoLk2a_m=lG2R|Ss@b&ubUO88e<=um4#-WjUd`LD%bUK#iT$`6?y9fb z{`9RoIomcr)|-u(E<eiWZujqo)BR(=9agKh8F$O={>QMppSSz#<);7GnMq?V4)_LX z0DjWwi=#``se^*w`~BTGJak=WY}U`oQu;Hx`tiWveN@ziXVP>&_~Vz0le}O3qNFch z&O)4p`8M_sohQoI&GJ9Gs{6iM-~S|%ehK64OOS(Y!gzXShXnpQ)#WE_-kp?A8m~{> z=T4jm<?rlaNeQXUpVp~Zf=S6uOjbJQXp?QOtX4tiV6~rEqp&PyZ<+<Ljm4N~1rEl? zoLyszDn1x<I2?V9MJGWc6xvsc6S@Gl$?1~cz{D=T=wjm=;;fR+o*3sUu1rwDI_*q2 z*Sr8WA!!SmRI)Z&Q4y4e*pgmSCdFuU5zu;0#yByN5H|8(jSzSrw9`glYjY@G&K7O7 z%XT6ktqpk8@Vhrj`{d8jD`JG`bWG(OUI^?2MnzCq5we3LDIbCs5kWcUL&`=#M{dzo zRBKRck(MZeiP1vr5tK<Z$YCqfZV~KNHa1WXK}|6_8Wek#Objtq27cF3lJm7PG1PFz z!K>?86a>@>gZ4&;j0le>FjNsy<wD$wA~J-GKNuK;4kDNqj5+WKO7Srhy-ww9LpU;6 zr1?NNr-IkPM?{Fl35&6Z4MsSxNrP&XLX{Eh6jcdM(x9F+m33)iEGc`8g@KF$bwh(y zk!Vf`yfmxU1p-j=u|=wk9yDl6=`0!$BRwo~Y$<fMppM?pR#4}1DbiD~s8CoJ5m!Y7 zXE}@rr%0`2I$I8h2#OP;J(rvg#8J*hR9ID*6I!eu#x|SjR!|Ft_ya`}Z6xw8Dij<o zDIKz=&bClHtBmoKscLiLHAP~>BwK)t$`si|UnOX=hy~kan1SUW{+HzN%+N+u&|?Ey zBAmGrXk=;2Hh3Qfja)#f;2BpeHfU3Gh$zS+3lz(=ag`#`Br;r}Qdt>K3Trv*5T?Z@ z&?*hbOO6NBWV%BbC=q2YGU1wr5S`WagdIGB$`UEkm0r<Kgs3QO<_q1v0ESf?@kfhz zUuZQt4F>uTdqE|hLmP_$$a@TeI8v=BO%+vP!h;Mmk_~Xdx1?fX5=(yLW>${5KjbW% z#~5MAoMo_6Ju9<p0t`iIn{=ry#XtrlC1!<EU^-2@1f$r71%@I!$;g0P<ua(G%oet? znro8;I@yY_qLK%VWNcXxIXK>2gdrzpqty1}DhSRu?0l`!$ki&ntQj;i24!(1BXdaK zQ)LIQTo4h|%5XWAfH6W2)27_K2xgzqlaZ9=Vu4nw%t}KU7taFF`C?pvfUU>`r7R~I zQ(}?Y>;9}a5DH}8UN`fVrF%42l!5mZUU1QgUe?z+49eAKYG=Q!e87^7p$Few57sNg z<%gp4y9AU~YqBG^*K6n9YqhN}LoaVwKe@@}8jiWp=F)5=&82wJym{f-4P$-V@hrpo lynP3HCFsr=THeg$+JEO2V|w!zoSg$NU+RaMys%z=_#a&si;n;R literal 0 HcmV?d00001 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 GIT binary patch literal 2848 zcmZ{m+fE}#6h+_XD>|K*AkpQzUq&_~#nI%2NTbZ;1037~7RHV24v@dkI@K6V#PkdH zcCpVs`&_E$pPufv%|pLGY=+&UHA=Tlzq=aNo89%I{p;K3F1O8LT<zAY?Xc???QUp4 zU3{1y9<H0m&3e4yfBgMZ+uZb<>zi@WX8H24=^uX`o))ccbYpx2$4Lm&HU!-r7VXVA z-hZ6U9v>goqf^8FdS<oOGsvHhe0<t&cDL^xG^LbT{k6UL(D2NE>bmCle%J3;<FNnO ze7)_*tDD9<Wt(4I(XpI0U2_ug@8PDu+pM0GRae8^>@fXL*F95?n{nG;?E7xtUk&^9 zd^Q17ymf!Lx>)Y})!47`V!U|{sphl#)A7{z!-XLHnUL#dvtG1cR@Z%p6Nhm>+*V(A z{qb9Obhd52Z?;=8U4E3$-Tt2~r~8L~H>}sssn_%;+TZ%a?Ks?bU1x08PsvixalhIf zz7PAmMO!~^QT$8ieN>pubKZ15_~$QY$Cy9+Wl5hupM*FG^L6a+J5Q7^+tt6cO80HQ zx%)vR{SwC8mmrfD!gze<Qv!b;&He$KcSoh?jn_x+v;WC_A-!Kw7wrDSIu%PWDY<i# zmCiZZWSdK?RnR$D?a!@ISQfK4%@WwgVobCG2jgSTt}#UwAB>p}M;~L+Nze#|_Lbs@ zE`e=wy5u)7v5POd*!YGxtE96h#;J-+6I8HHI}=VdFM&-++JYvPtc_Mw1f?Oiq?eRQ zF&bS2w4ReOo|{Mr8~LwB2)qy4X(O<;ITSBvi#FP2doCZX4S3Y>yEjSu<WJE{Vua{) zOyv|_3hV?%MNn7~vV$WjAA%MUK{@9`%0@s(ZqZd#Yfx*EmMDUW(L(GIlu0zmVJjnS z5$shqHc$^iO))we6nm9S3^7#(e%Dcw^R+TD)NsbZtLs@51k?$G_C|+{2#+T)R1s0- zLfncXGK7pj7#M>NBA6D8Iq(Qd@i7y<PUUPvcxJFj^MP<q1+RmTh!Bet7Gn=<iEv(% z2GuBqDkIn_suG-}K|N_I>(aTgr0g*k1~LlN4GmUBqB$Y((yUq+2tdil7O66N(4Z}) zvuHq!^svaWrO?@eI(k1_L7mH`NKd_@LSbD*Ton<V<uD?gBDIp~Y&jevC{Bp>TymO- zqnwSXu&OX8v{*fiZ8p=bpcV@82Z|)xNaS5qC^%YDnzE+Owop5(jPaDIYIEW>MPkDw zTY!wp6xl>yC1|pU1>0qqf#o3nm*nuw&_-0yV*^?uoVgNcWNFJbcpnCh+$yQy8TTeO zXj5~DD99oU6w9=6l_Jq3GF+fiSs70XYdPx>ro|@EDh<a=juUD!-60H=h%y(Ma7{yq z&gy!?4jw^ei4^HduV^PiRFpRJg>GL0!>Wz=qeZ+gv>KfT1O11+pc2oajl}@uJqAG> zsaBMximEW-L53O02Dso`QZX@!CBJbqE63bVIm_lTMi??@8SGTg$}F1zLs8l$U201) zkikfaS)mk|PLnRdD7ImNp~y}$GT>IZ3@RzJg{`dS+9ZKawj!*k<Uu1DTUJC4j@J=k z$cfn~wf(pXg7XbKUu!fPG|<bMK_g>O7DqBNhx9#FcJRst5kajCms1HCBjhk`%FT;l z_6a>1Nm(uyXr;=mG?a1iECHP_#svu2icC<-a*{D67OB1N&w2x)K<4drGhbS|M{`9P zcwgZq7oF&3eVxLfTz#f?_RGo#EXf#p@XhsLy)s;WC_24MKv}gWJ92xycHX^K+xmv{ z^4j!+n_RBpm<w$#%|_B(if7H?YPIF<!~mW(`(d2kRal1gIh$T)rZ<=qg6@Q&<#kK0 Z{dZn5X4h}Q`6=-7rGA*pTj<4y{{XQPevtqG literal 0 HcmV?d00001 diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index c209d507a..88fb5773f 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 240e42719..21c2613bf 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 6f3bb4841..7170b645a 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 076dd860b..644d330d0 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 b3b744235..6c12f4730 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 cca22eea9..2d1920063 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); + }); }); -- GitLab