diff --git a/src/assets/main.css b/src/assets/main.css index 6ac709b6..63c0cae2 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -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: '↻'; +} diff --git a/src/components/ScaleControls.vue b/src/components/ScaleControls.vue index 585d6314..25a0925c 100644 --- a/src/components/ScaleControls.vue +++ b/src/components/ScaleControls.vue @@ -54,7 +54,7 @@ defineExpose({ focus, clearPaletteInfo }) type="number" step="1" v-model="scale.baseMidiNote" - @input="updateScale" + @input="updateScale()" /> @@ -66,7 +66,7 @@ defineExpose({ focus, clearPaletteInfo }) step="any" v-model="scale.baseFrequencyDisplay" :disabled="scale.autoFrequency" - @input="updateScale" + @input="updateScale()" />
@@ -74,7 +74,7 @@ defineExpose({ focus, clearPaletteInfo }) id="auto-frequency" type="checkbox" v-model="scale.autoFrequency" - @input="updateScale" + @input="updateScale()" />
@@ -85,7 +85,7 @@ defineExpose({ focus, clearPaletteInfo }) id="colors-silver" value="silver" v-model="scale.autoColors" - @input="updateScale" + @input="updateScale()" /> @@ -96,7 +96,7 @@ defineExpose({ focus, clearPaletteInfo }) id="colors-cents" value="cents" v-model="scale.autoColors" - @input="updateScale" + @input="updateScale()" /> @@ -107,7 +107,7 @@ defineExpose({ focus, clearPaletteInfo }) id="colors-factors" value="factors" v-model="scale.autoColors" - @input="updateScale" + @input="updateScale()" /> @@ -115,14 +115,24 @@ defineExpose({ focus, clearPaletteInfo })
-

Scale data

+

+ Scale data + + +

@@ -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; +} diff --git a/src/stores/scale.ts b/src/stores/scale.ts index d17b1ec5..bd26a6fe 100644 --- a/src/stores/scale.ts +++ b/src/stores/scale.ts @@ -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) { @@ -365,7 +366,7 @@ export const useScaleStore = defineStore('scale', () => { } } - function computeScale() { + function computeScale(pushUndo = true) { try { error.value = '' warning.value = '' @@ -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 @@ -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, @@ -599,6 +619,8 @@ export const useScaleStore = defineStore('scale', () => { colorForIndex, labelForIndex, toJSON, - fromJSON + fromJSON, + // Mini-stores + history } }) diff --git a/src/undo.ts b/src/undo.ts new file mode 100644 index 00000000..09dbcf63 --- /dev/null +++ b/src/undo.ts @@ -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([]) + 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 + } +} diff --git a/src/views/ScaleView.vue b/src/views/ScaleView.vue index 9d202ba5..9e3e9bd1 100644 --- a/src/views/ScaleView.vue +++ b/src/views/ScaleView.vue @@ -38,7 +38,7 @@ onMounted(() => { placeholder="Untitled scale" v-model="scale.name" @focus="controls!.clearPaletteInfo" - @update="updateScale" + @update="updateScale()" >