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
+
+
+
+
+
@@ -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()"
>