Skip to content

Commit

Permalink
Undo system
Browse files Browse the repository at this point in the history
ref #719
  • Loading branch information
frostburn committed Jun 11, 2024
1 parent b97516d commit 71abea8
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 11 deletions.
18 changes: 18 additions & 0 deletions src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,21 @@ div.alert-box-danger {
p.alert-message-danger {
color: var(--color-alert-danger);
}

.undo,
.redo {
cursor: pointer;
}

.undo.disabled,
.redo.disabled {
cursor: default;
color: var(--color-text-mute);
}

.undo::after {
content: '↺';
}
.redo::after {
content: '↻';
}
34 changes: 26 additions & 8 deletions src/components/ScaleControls.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ defineExpose({ focus, clearPaletteInfo })
type="number"
step="1"
v-model="scale.baseMidiNote"
@input="updateScale"
@input="updateScale()"
/>
</div>

Expand All @@ -66,15 +66,15 @@ defineExpose({ focus, clearPaletteInfo })
step="any"
v-model="scale.baseFrequencyDisplay"
:disabled="scale.autoFrequency"
@input="updateScale"
@input="updateScale()"
/>
</div>
<div class="control checkbox-container">
<input
id="auto-frequency"
type="checkbox"
v-model="scale.autoFrequency"
@input="updateScale"
@input="updateScale()"
/><label for="auto-frequency">Automatic base frequency</label>
</div>
<div class="control radio-group">
Expand All @@ -85,7 +85,7 @@ defineExpose({ focus, clearPaletteInfo })
id="colors-silver"
value="silver"
v-model="scale.autoColors"
@input="updateScale"
@input="updateScale()"
/>
<label for="colors-silver">Silver</label>
</span>
Expand All @@ -96,7 +96,7 @@ defineExpose({ focus, clearPaletteInfo })
id="colors-cents"
value="cents"
v-model="scale.autoColors"
@input="updateScale"
@input="updateScale()"
/>
<label for="colors-cents">Cents</label>
</span>
Expand All @@ -107,22 +107,32 @@ defineExpose({ focus, clearPaletteInfo })
id="colors-factors"
value="factors"
v-model="scale.autoColors"
@input="updateScale"
@input="updateScale()"
/>
<label for="colors-factors">Factors</label>
</span>
</div>
</div>

<div class="control-group">
<h2>Scale data</h2>
<h2>
<span class="scale-data-header">Scale data</span>
<span
:class="{ undo: true, disabled: scale.history.undoDisabled }"
@click="scale.history.undo"
></span>
<span
:class="{ redo: true, disabled: scale.history.redoDisabled }"
@click="scale.history.redo"
></span>
</h2>
<div class="control">
<textarea
id="scale-data"
ref="sourceEditor"
rows="20"
v-model="scale.sourceText"
@input="updateScale"
@input="updateScale()"
@focus="clearPaletteInfo"
></textarea>
</div>
Expand All @@ -149,4 +159,12 @@ defineExpose({ focus, clearPaletteInfo })
height: 3em;
overflow-y: hidden;
}
.scale-data-header {
pointer-events: none;
user-select: none;
}
.undo,
.redo {
margin-left: 1em;
}
</style>
26 changes: 24 additions & 2 deletions src/stores/scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { pianoMap } from 'isomorphic-qwerty'
import { computeWhiteIndices } from '@/midi'
import { midiKeyInfo } from 'xen-midi'
import { undoHistory } from '@/undo'

// Colors from #1 to #12 inclusive.
function defaultColors(base: number) {
Expand Down Expand Up @@ -365,7 +366,7 @@ export const useScaleStore = defineStore('scale', () => {
}
}

function computeScale() {
function computeScale(pushUndo = true) {
try {
error.value = ''
warning.value = ''
Expand Down Expand Up @@ -434,6 +435,10 @@ export const useScaleStore = defineStore('scale', () => {
const rootFrequency = autoFrequency.value ? 'mtof(_)' : 'baseFrequency'
warning.value = `Base MIDI note ${noteNumber} defaults to ${midiName}. Use an explicit ${enharmonics[0]} = ${rootFrequency} or ${enharmonics[1]} = ${rootFrequency} to get rid of this warning.`
}
// It's better to use a truthy value for this in case an event is passed to computeScale by accident.
if (pushUndo) {
history.pushState()
}
} catch (e) {
if (e instanceof Error) {
error.value = e.message
Expand Down Expand Up @@ -567,6 +572,21 @@ export const useScaleStore = defineStore('scale', () => {
uploadedId.value = data['id']
}

function serialize() {
// We could store the whole state using toJSON() here, but lets see if sourceText is enough...
return sourceText.value
}

function restore(body: string) {
// We could restore the whole state using something like this:
// const data = JSON.parse(body, (key, value) => Interval.reviver(key, Scale.reviver(key, value)))

sourceText.value = body
computeScale(false)
}

const history = undoHistory(serialize, restore)

return {
// Live state
...LIVE_STATE,
Expand Down Expand Up @@ -599,6 +619,8 @@ export const useScaleStore = defineStore('scale', () => {
colorForIndex,
labelForIndex,
toJSON,
fromJSON
fromJSON,
// Mini-stores
history
}
})
54 changes: 54 additions & 0 deletions src/undo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { computed, reactive, ref } from 'vue'

type Serializer = () => string
type Restorer = (state: string) => void

export function undoHistory(serialize: Serializer, restore: Restorer, capacity = 10) {
const states = reactive<string[]>([])
const pointer = ref(-1)

pushState()

function pushState() {
while (states.length > pointer.value + 1) {
states.pop()
}
states.push(serialize())
while (states.length > capacity) {
states.shift()
}
pointer.value = states.length - 1
}

function undo() {
pointer.value--
if (pointer.value < 0) {
pointer.value = -1
return
}
restore(states[pointer.value])
}

function redo() {
pointer.value++
if (pointer.value >= states.length) {
pointer.value = states.length - 1
return
}
restore(states[pointer.value])
}

const undoDisabled = computed(() => pointer.value <= 0)

const redoDisabled = computed(() => pointer.value >= states.length - 1)

return {
states,
pointer,
pushState,
undo,
redo,
undoDisabled,
redoDisabled
}
}
2 changes: 1 addition & 1 deletion src/views/ScaleView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ onMounted(() => {
placeholder="Untitled scale"
v-model="scale.name"
@focus="controls!.clearPaletteInfo"
@update="updateScale"
@update="updateScale()"
></textarea>
<ul class="btn-group">
<NewScale ref="newScale" @done="controls!.focus()" @mouseenter="modifyScale!.blur()" />
Expand Down

0 comments on commit 71abea8

Please sign in to comment.