Skip to content

Commit

Permalink
Added undo/redo to touch screen interface list
Browse files Browse the repository at this point in the history
  • Loading branch information
Igor Zinken committed Aug 11, 2019
1 parent c18381e commit 6151984
Show file tree
Hide file tree
Showing 7 changed files with 146 additions and 98 deletions.
Binary file modified src/assets/images/icon-module-glide.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src/assets/images/icon-module-params.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/icon-redo.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/assets/images/icon-undo.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
60 changes: 39 additions & 21 deletions src/components/track-editor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,19 @@
:class="{ fixed: isFixed }"
>
<ul class="controls">
<li class="addNote" @click="handleNoteAddClick"></li>
<li class="addOff" @click="handleNoteOffClick"></li>
<li class="removeNote" @click="handleNoteDeleteClick"></li>
<li class="moduleParams" @click="handleModuleParamsClick"></li>
<li class="moduleGlide" @click="handleModuleGlideClick"></li>
<li class="undo" :class="{ disabled: !canUndo }" @click="undo"></li>
<li class="redo" :class="{ disabled: !canRedo }" @click="redo"></li>
<li class="add-on" @click="addNoteOn"></li>
<li class="add-off" @click="addNoteOnOff"></li>
<li class="remove-note" @click="deleteNote"></li>
<li class="module-params" @click="editModuleParams"></li>
<li class="module-glide" @click="glideParams"></li>
</ul>
</section>
</template>

<script>
import { mapState, mapMutations } from 'vuex';
import { mapState, mapGetters, mapMutations, mapActions } from 'vuex';
import HistoryStates from '../definitions/history-states';
import ModalWindows from '../definitions/modal-windows';
import EventFactory from '../model/factory/event-factory';
Expand All @@ -56,6 +58,10 @@ export default {
'windowSize',
'windowScrollOffset',
]),
...mapGetters([
'canUndo',
'canRedo'
]),
...mapState({
activeSong: state => state.song.activeSong,
selectedStep: state => state.editor.selectedStep,
Expand Down Expand Up @@ -83,23 +89,27 @@ export default {
...mapMutations([
'addEventAtPosition',
'openModal',
'saveState',
'saveState'
]),
...mapActions([
'undo',
'redo'
]),
handleNoteAddClick() {
addNoteOn() {
this.openModal(ModalWindows.NOTE_ENTRY_EDITOR);
},
handleNoteOffClick(){
addNoteOnOff(){
const offEvent = EventFactory.createAudioEvent();
offEvent.action = 2; // noteOff;
this.addEventAtPosition({ event: offEvent, store: this.$store });
},
handleNoteDeleteClick() {
deleteNote() {
this.saveState(HistoryStateFactory.getAction(HistoryStates.DELETE_EVENT, { store: this.$store }));
},
handleModuleParamsClick() {
editModuleParams() {
this.openModal(ModalWindows.MODULE_PARAM_EDITOR);
},
handleModuleGlideClick() {
glideParams() {
EventUtil.glideParameterAutomations(
this.activeSong, this.selectedStep, this.activePattern,
this.selectedInstrument, this.eventList, this.$store,
Expand All @@ -120,9 +130,7 @@ export default {
min-width: 40px;
.controls {
li {
width: 40px;
height: 40px;
margin: 0 0 1px;
Expand All @@ -132,25 +140,36 @@ export default {
background-size: 50%;
cursor: pointer;
&.addNote {
&.add-on {
background-image: url('../assets/images/icon-note-add.png');
}
&.addOff {
&.add-off {
background-image: url('../assets/images/icon-note-mute.png');
}
&.removeNote {
&.remove-note {
background-image: url('../assets/images/icon-note-delete.png');
}
&.moduleParams {
&.module-params {
background-image: url('../assets/images/icon-module-params.png');
}
&.moduleGlide {
&.module-glide {
background-image: url('../assets/images/icon-module-glide.png');
}
&.undo {
background-image: url('../assets/images/icon-undo.png');
}
&.redo {
background-image: url('../assets/images/icon-redo.png');
}
&:hover {
background-color: $color-1;
}
&.disabled {
opacity: .25;
@include noEvents();
}
}
}
Expand All @@ -164,8 +183,7 @@ export default {
/* mobile view */
@media screen and ( max-width: $mobile-width )
{
@media screen and ( max-width: $mobile-width ) {
#trackEditor {
position: fixed; /* keep pattern editor in static position */
left: 0;
Expand Down
20 changes: 14 additions & 6 deletions src/store/modules/history-module.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,18 @@ const STATES_TO_SAVE = 99;

const module = {
state: {
undoManager: new UndoManager()
undoManager: new UndoManager(),
historyIndex: -1 // used for reactivity (as undo manager isn't bound to Vue)
},
getters: {
canUndo(state) {
return state.undoManager.hasUndo();
return state.historyIndex >= 0 && state.undoManager.hasUndo();
},
canRedo(state) {
return state.undoManager.hasRedo();
return state.historyIndex < STATES_TO_SAVE && state.undoManager.hasRedo();
},
amountOfStates(state) {
return state.undoManager.getIndex() + 1;
return state.historyIndex + 1;
}
},
mutations: {
Expand All @@ -59,33 +60,40 @@ const module = {
throw new Error( 'cannot store a state without specifying valid undo and redo actions' );

state.undoManager.add({ undo, redo });
state.historyIndex = state.undoManager.getIndex();
},
setHistoryIndex(state, value) {
state.historyIndex = value;
},
/**
* clears entire history
*/
resetHistory(state) {
state.undoManager.clear();
state.historyIndex = state.undoManager.getIndex();
}
},
actions: {
/**
* apply the previously stored state
*/
undo({ state, getters }) {
undo({ state, getters, commit }) {
return new Promise(resolve => {
if (getters.canUndo) {
state.undoManager.undo();
commit('setHistoryIndex', state.undoManager.getIndex());
}
resolve(); // always resolve, application should not break if history cannot be accessed
});
},
/**
* apply the next stored state
*/
redo({ state, getters }) {
redo({ state, getters, commit }) {
return new Promise(resolve => {
if (getters.canRedo) {
state.undoManager.redo();
commit('setHistoryIndex', state.undoManager.getIndex());
}
resolve(); // always resolve, application should not break if history cannot be accesse
});
Expand Down
164 changes: 93 additions & 71 deletions tests/unit/store/modules/history-module.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,95 +3,117 @@ import store from '@/store/modules/history-module';
const { getters, mutations, actions } = store;

describe( 'History State module', () => {
const noop = () => {}, MIN_STATES = 5;
let amountOfStates, state;
const noop = () => {}, AMOUNT_OF_STATES = 5;
let commit = jest.fn();
let state;

beforeEach( () => {
state = { undoManager: new UndoManager() };
amountOfStates = Math.round( Math.random() * 100 ) + MIN_STATES;
state.undoManager.setLimit(amountOfStates);
state = { undoManager: new UndoManager(), historyIndex: -1 };
state.undoManager.setLimit(AMOUNT_OF_STATES);
});

/* actual unit tests */
describe('getters', () => {
it('should know when it can undo an action', async () => {
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(false); // expected no undo to be available after construction
mutations.saveState(state, { undo: noop, redo: noop });
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(true); // expected undo to be available after addition of action
await actions.undo({ state, commit, getters: { canUndo: state.undoManager.hasUndo() }});
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(false); // expected no undo to be available after having undone all actions
});

it('should not store a state that does not supply undo/redo functions', () => {
expect(() => mutations.saveState(state, {})).toThrowError( /cannot store a state without specifying valid undo and redo actions/ );
mutations.saveState(state, { undo: noop, redo: noop }); // should not throw
});
it('should know when it can redo an action', async () => {
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after construction
mutations.saveState(state, { undo: noop, redo: noop });
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after addition of action
await actions.undo({ state, commit, getters: { canUndo: state.undoManager.hasUndo() }});
expect(getters.canRedo(state)).toBe(true); // expected redo to be available after having undone actions
await actions.redo({ state, commit, getters: { canRedo: state.undoManager.hasRedo() }});
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after having redone all actions
});

it('should be able to undo an action when an action was stored in its state history', () => {
const undo = jest.fn();
mutations.saveState(state, { undo: undo, redo: noop });
return actions.undo({ state, getters: { canUndo: state.undoManager.hasUndo() }}).then(() => {
expect(undo).toHaveBeenCalled();
it('should know the amount of states it has stored', async () => {
commit = (action, value) => state.historyIndex = value;
expect(0).toEqual(getters.amountOfStates(state)); // expected no states to be present after construction

for ( let i = 0; i < AMOUNT_OF_STATES; ++i ) {
mutations.saveState(state, { undo: noop, redo: noop });
expect(i + 1).toEqual(getters.amountOfStates(state)); // expected amount of states to increase when storing new states
}

for ( let i = AMOUNT_OF_STATES - 1; i >= 0; --i ) {
await actions.undo({ state, commit, getters: { canUndo: state.undoManager.hasUndo() }});
expect(i).toEqual(getters.amountOfStates(state)); // expected amount of states to decrease when performing undo
}

for ( let i = 0; i < AMOUNT_OF_STATES; ++i ) {
await actions.redo({ state, commit, getters: { canRedo: state.undoManager.hasRedo() }});
expect(i + 1).toEqual(getters.amountOfStates(state)); // expected amount of states to increase when performing redo
}
});
});

it('should be able to redo an action', () => {
const redo = jest.fn();
mutations.saveState(state, { undo: noop, redo: redo });
return actions.undo({ state, getters: { canUndo: state.undoManager.hasUndo() }}).then(() => {
actions.redo({ state, getters: { canRedo: state.undoManager.hasRedo() }}).then(() => {
expect(redo).toHaveBeenCalled();
});
describe('mutations', () => {
it('should not store a state that does not supply undo/redo functions', () => {
expect(() => mutations.saveState(state, {})).toThrowError( /cannot store a state without specifying valid undo and redo actions/ );
});
});

it('should know when it can undo an action', async () => {
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(false); // expected no undo to be available after construction
mutations.saveState(state, { undo: noop, redo: noop });
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(true); // expected undo to be available after addition of action
await actions.undo({ state, getters: { canUndo: state.undoManager.hasUndo() }});
expect(getters.canUndo(state, { canUndo: state.undoManager.hasUndo() })).toBe(false); // expected no undo to be available after having undone all actions
});
it('should be able to store a state and increment the history index', () => {
mutations.saveState(state, { undo: noop, redo: noop });
expect(state.historyIndex).toEqual(0);
mutations.saveState(state, { undo: noop, redo: noop });
expect(state.historyIndex).toEqual(1);
});

it('should know when it can redo an action', async () => {
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after construction
mutations.saveState(state, { undo: noop, redo: noop });
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after addition of action
await actions.undo({ state, getters: { canUndo: state.undoManager.hasUndo() }});
expect(getters.canRedo(state)).toBe(true); // expected redo to be available after having undone actions
await actions.redo({ state, getters: { canRedo: state.undoManager.hasRedo() }});
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after having redone all actions
});
it('should not store more states than are allowed', () => {
for ( let i = 0; i < AMOUNT_OF_STATES * 2; ++i ) {
mutations.saveState(state, { undo: noop, redo: noop });
}
expect(getters.amountOfStates(state)).toEqual(AMOUNT_OF_STATES); // expected model to not have recorded more states than the defined maximum
});

it('should know the amount of states it has stored', async () => {
expect(0).toEqual(getters.amountOfStates(state)); // expected no states to be present after construction
it('should be able to set the history index', () => {
mutations.setHistoryIndex(state, 2);
expect(state.historyIndex).toEqual(2);
});

for ( let i = 0; i < MIN_STATES; ++i ) {
mutations.saveState(state, { undo: noop, redo: noop });
expect(( i + 1 )).toEqual(getters.amountOfStates(state)); // expected amount of states to increase when storing new states
}

for ( let i = MIN_STATES - 1; i >= 0; --i ) {
await actions.undo({ state, getters: { canUndo: state.undoManager.hasUndo() }});
expect(i).toEqual(getters.amountOfStates(state)); // expected amount of states to decrease when performing undo
}

for ( let i = 0; i < MIN_STATES; ++i ) {
await actions.redo({ state, getters: { canRedo: state.undoManager.hasRedo() }});
expect(( i + 1 )).toEqual(getters.amountOfStates(state)); // expected amount of states to increase when performing redo
}
});
it('should be able to clear its history', () => {
function shouldntRun() {
throw new Error('undo/redo callback should not have fired after clearing the undo history');
}
mutations.saveState(state, { undo: shouldntRun, redo: shouldntRun });
mutations.saveState(state, { undo: shouldntRun, redo: shouldntRun });

mutations.resetHistory(state);

it('should not store more states than it allows', async () => {
for ( let i = 0; i < amountOfStates * 2; ++i ) {
await mutations.saveState(state, { undo: noop, redo: noop });
}
expect(getters.amountOfStates(state)).toEqual(amountOfStates); // expected model to not have recorded more states than the defined maximum
expect(state.historyIndex).toEqual(-1);
expect(getters.canUndo(state)).toBe(false); // expected no undo to be available after flushing of history
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after flushing of history
expect(0).toEqual(getters.amountOfStates(state)); // expected no states to be present in history
});
});

it('should be able to clear its history', () => {
function shouldntRun() {
throw new Error('undo/redo callback should not have fired after clearing the undo history');
}
mutations.saveState(state, { undo: shouldntRun, redo: shouldntRun });
mutations.saveState(state, { undo: shouldntRun, redo: shouldntRun });
describe('actions', () => {
it('should be able to redo an action', async () => {
commit = jest.fn();
const redo = jest.fn();
mutations.saveState(state, { undo: noop, redo: redo });

mutations.resetHistory(state);
await actions.undo({ state, commit, getters: { canUndo: state.undoManager.hasUndo() }});
expect(commit).toHaveBeenNthCalledWith(1, 'setHistoryIndex', -1);
await actions.redo({ state, commit, getters: { canRedo: state.undoManager.hasRedo() }});

expect(getters.canUndo(state)).toBe(false); // expected no undo to be available after flushing of history
expect(getters.canRedo(state)).toBe(false); // expected no redo to be available after flushing of history
expect(0).toEqual(getters.amountOfStates(state)); // expected no states to be present in history
expect(redo).toHaveBeenCalled();
expect(commit).toHaveBeenNthCalledWith(2, 'setHistoryIndex', 0);
});

it('should be able to undo an action when an action was stored in its state history', async () => {
const undo = jest.fn();
mutations.saveState(state, { undo: undo, redo: noop });

await actions.undo({ state, commit, getters: { canUndo: state.undoManager.hasUndo() }});

expect(undo).toHaveBeenCalled();
expect(state.historyIndex).toEqual(0);
});
});
});

0 comments on commit 6151984

Please sign in to comment.