diff --git a/.gitignore b/.gitignore index e5cab8c..8a2cd7b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /public/static/*.js.map /dist .idea +.DS_STORE diff --git a/src/AppState.ts b/src/AppState.ts index 936cbcd..c6f70ed 100644 --- a/src/AppState.ts +++ b/src/AppState.ts @@ -1,33 +1,26 @@ -import { inject, type InjectionKey, ref } from "vue"; +import { inject, type InjectionKey, ref, provide, getCurrentInstance } from "vue"; +import { Bound } from "./utils"; export type UITool = | "track-unit-type" | "eraser" | "sticking"; -export function createAppStateStore() { - const selectedTool = ref("track-unit-type"); - const activeTrackUnitType = ref(0); - const activeStickingType = ref(1); - const unitMouseStart = ref(null); - const selectingUnits = ref(false); - const deselectingUnits = ref(false); - - return { - selectingUnits, - deselectingUnits, - selectedTool, - activeTrackUnitType, - activeStickingType, - unitMouseStart, - }; +export class AppStateStore extends Bound { + selectedTool = ref("track-unit-type"); + activeTrackUnitType = ref(0); + activeStickingType = ref(1); + unitMouseStart = ref(null); + selectingUnits = ref(false); + deselectingUnits = ref(false); } - -export type AppStateStore = ReturnType; - -export const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey; +const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey; export function useAppStateStore(): AppStateStore { - return inject(AppStateStoreKey, createAppStateStore, true); + return inject(AppStateStoreKey, () => { + const store = new AppStateStore(); + getCurrentInstance()?.appContext?.app.provide(AppStateStoreKey, store); + return store; + }, true); } diff --git a/src/Beat.ts b/src/Beat.ts index 3372ffa..42b779a 100644 --- a/src/Beat.ts +++ b/src/Beat.ts @@ -1,7 +1,6 @@ import { deserialise as deserialiseTrack, type TrackInitOptions, isValidTimeSigRange, Track } from "@/Track"; -import { greatestCommonDivisor, isPosInt } from "@/utils"; -import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef } from "vue"; -import EffectScoped from "./EffectScoped"; +import { greatestCommonDivisor, isPosInt, EffectScoped } from "@/utils"; +import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef, nextTick } from "vue"; import { z } from "zod"; export interface BeatManager { @@ -9,7 +8,6 @@ export interface BeatManager { } type BeatGroupInitOptions = { - manager: BeatManager, barCount: number; isLooping: boolean; timeSigUp: number; @@ -20,7 +18,7 @@ type BeatGroupInitOptions = { }; const BeatSerialSchema = z.object({ - tracks: z.array(z.any()), + tracks: z.array(z.unknown()), barCount: z.number(), timeSigUp: z.number(), globalLoopLength: z.number(), @@ -32,9 +30,11 @@ const BeatSerialSchema = z.object({ export type BeatSerial = z.infer; export class Beat extends EffectScoped { + private static count = 0; private barCountInternal: Ref; private globalLoopLengthInternal: Ref; - manager: BeatManager; + readonly id = Beat.count++; + tracks: ShallowRef; barCount: WritableComputedRef; timeSigUp: Ref; @@ -43,10 +43,10 @@ export class Beat extends EffectScoped { useAutoBeatLength: Ref; barSettingsLocked: Ref; name: Ref; + saveDirty = ref(false); constructor(opts: BeatGroupInitOptions) { super(); - this.manager = opts.manager; this.tracks = shallowRef([]); this.barCountInternal = ref(opts.barCount ?? 4); this.barCount = computed({ @@ -89,14 +89,17 @@ export class Beat extends EffectScoped { this.globalIsLooping = ref(opts.isLooping ?? false); this.useAutoBeatLength = ref(opts.useAutoBeatLength ?? false); this.barSettingsLocked = computed(() => this.useAutoBeatLength.value); - this.name = ref(opts.name ?? "Beat"); + this.name = ref(opts.name ?? `Beat-${ this.id }`); - watch([this.barCount, this.timeSigUp], ([newBarCount, newTimeSigUp]) => { + watch([this.barCount, this.timeSigUp, this.name], ([newBarCount, newTimeSigUp]) => { for (const track of this.tracks.value) { track.barCount.value = newBarCount; track.timeSigUp.value = newTimeSigUp; } + this.saveDirty.value = true; }, { immediate: true }); + + nextTick(() => this.saveDirty.value = false); } setTimeSigUp(timeSigVal: number): void { @@ -124,9 +127,13 @@ export class Beat extends EffectScoped { triggerRef(this.tracks); } + notifyChange() { + this.saveDirty.value = true; + } + addTrack(options?: Omit): Track | null { const optionsResolved = { - manager: this.manager, + manager: this, barCount: this.barCount.value, isLooping: this.globalIsLooping.value, loopLength: this.globalLoopLength.value, @@ -173,7 +180,7 @@ export class Beat extends EffectScoped { } } -export function deserialise(serial: unknown, manager: BeatManager): Beat | null { +export function deserialise(serial: unknown): Beat | null { const parse = BeatSerialSchema.safeParse(serial); if (!parse.success) { console.error('Encountered invalid beat serial:', parse.error, serial); @@ -181,7 +188,6 @@ export function deserialise(serial: unknown, manager: BeatManager): Beat | null } const beat = parse.data; const newBeat = Beat.asScoped({ - manager, loopLength: beat.globalLoopLength, barCount: beat.barCount, isLooping: beat.globalIsLooping, @@ -190,7 +196,7 @@ export function deserialise(serial: unknown, manager: BeatManager): Beat | null useAutoBeatLength: beat.useAutoBeatLength, }); beat.tracks.forEach(trackSerial => { - const track = deserialiseTrack(trackSerial, manager); + const track = deserialiseTrack(trackSerial, newBeat); if (track) newBeat.tracks.value.push(track); }); return newBeat; diff --git a/src/BeatStore.ts b/src/BeatStore.ts index 6665e01..ae25ea8 100644 --- a/src/BeatStore.ts +++ b/src/BeatStore.ts @@ -1,6 +1,7 @@ import { Beat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat"; -import { inject, computed, type InjectionKey, onScopeDispose, ref, shallowRef, triggerRef, watch, nextTick } from "vue"; +import { inject, computed, type InjectionKey, onScopeDispose, ref, shallowRef, triggerRef, watch, nextTick, readonly, provide, getCurrentInstance } from "vue"; import { z } from "zod"; +import { Bound } from "./utils"; export const DrumSlayerSaveSchema = z.object({ orientation: z.union([z.literal("horizontal"), z.literal("vertical")]), @@ -9,9 +10,8 @@ export const DrumSlayerSaveSchema = z.object({ }); export type DrumSlayerSave = z.infer; -function createDefaultMainBeatGroup(manager: BeatManager): Beat { +function createDefaultMainBeatGroup(): Beat { const defaultSettings = { - manager, barCount: 2, isLooping: false, timeSigUp: 8, @@ -24,117 +24,123 @@ function createDefaultMainBeatGroup(manager: BeatManager): Beat { return mainBeatGroup; } -export function createBeatStore() { - const saveDirty = ref(false); +export class BeatStore extends Bound { + private saveDirtyGlobal = ref(false); + beats = shallowRef([ createDefaultMainBeatGroup() ]); + saveDirty = computed(() => this.saveDirtyGlobal.value || this.beats.value.reduce((last, beat) => beat.saveDirty.value || last, false)); + activeBeatIndex = ref(0); + activeBeat = computed(() => this.beats.value[this.activeBeatIndex.value] ?? null); + orientation = ref<"horizontal" | "vertical">("horizontal"); - const manager = { - notifyChange() { - saveDirty.value = true; - }, - }; + constructor() { + super(); + watch([this.activeBeatIndex, this.orientation, this.beats], () => { + this.saveDirtyGlobal.value = true; + }); - const beats = shallowRef([ createDefaultMainBeatGroup(manager) ]); - const activeBeatIndex = ref(0); - const activeBeat = computed(() => beats.value[activeBeatIndex.value] ?? null); - const orientation = ref<"horizontal" | "vertical">("horizontal"); + const saveInterval = setInterval(() => this.saveDirtyGlobal.value && this.save("localStorage"), 5 * 60 * 1000); - function resetActiveBeat(): void { - const current = activeBeat.value; - beats.value[activeBeatIndex.value] = createDefaultMainBeatGroup(manager); + onScopeDispose(() => clearInterval(saveInterval)); + + window.addEventListener("beforeunload", (e) => { + if (this.saveDirty.value) { + e.preventDefault(); + e.returnValue = true; + } + }); + + const savedItem = localStorage.getItem("drum-slayer-save"); + if (savedItem) { + const serial = JSON.parse(savedItem); + this.beats.value = [createDefaultMainBeatGroup()]; + this.loadFromSave(serial); + } + } + + resetActiveBeat(): void { + const current = this.activeBeat.value; + this.beats.value[this.activeBeatIndex.value] = createDefaultMainBeatGroup(); current?.destroy(); - triggerRef(beats); + triggerRef(this.beats); } - function removeBeat(index: number): void { - const beat = beats.value[index]; - beats.value.splice(index, 1); - beat?.destroy(); - triggerRef(beats); + removeBeat(index: number): void { + const beat = this.beats.value[index]; + this.beats.value.splice(index, 1); + if (this.activeBeatIndex.value === index) { + this.activeBeatIndex.value = 0; + } + if (this.beats.value.length === 0) { + this.addNewBeat(); + this.activeBeatIndex.value = 0; + } + triggerRef(this.beats); + nextTick(() => { + beat?.destroy(); + }); } - function addNewBeat(): number { - const newBeat = createDefaultMainBeatGroup(manager); - const newIndex = beats.value.push(newBeat); - triggerRef(beats); + addNewBeat(config?: unknown): number { + let newBeat: Beat | null = null; + if (config) { + newBeat = deserialiseBeat(config); + if (!newBeat) { + return -1; + } + } else { + newBeat = createDefaultMainBeatGroup(); + } + const newIndex = this.beats.value.push(newBeat); + triggerRef(this.beats); + this.activeBeatIndex.value = newIndex - 1; return newIndex - 1; } - function save(destination: "localStorage"): void { + save(destination: "localStorage"): void { if (destination === "localStorage") { - const serials = beats.value.map(beat => beat.serialise()); + const serials = this.beats.value.map(beat => beat.serialise()); localStorage.setItem("drum-slayer-save", JSON.stringify({ beats: serials, - activeBeatIndex: activeBeatIndex.value, - orientation: orientation.value ?? "horizontal", + activeBeatIndex: this.activeBeatIndex.value, + orientation: this.orientation.value ?? "horizontal", } satisfies DrumSlayerSave)); - saveDirty.value = false; + for (const beat of this.beats.value) { + beat.saveDirty.value = false; + } + this.saveDirtyGlobal.value = false; } } - function loadFromSave(source: unknown): void { - beats.value.length = 0; + loadFromSave(source: unknown): void { + this.beats.value.length = 0; const parse = DrumSlayerSaveSchema.safeParse(source); if (parse.success) { parse.data.beats.forEach((beat: unknown) => { - const deserialisedBeat = deserialiseBeat(beat, manager); + const deserialisedBeat = deserialiseBeat(beat); if (deserialisedBeat) { - beats.value.push(deserialisedBeat); + this.beats.value.push(deserialisedBeat); } }); - activeBeatIndex.value = parse.data.activeBeatIndex; - orientation.value = parse.data.orientation; + this.activeBeatIndex.value = parse.data.activeBeatIndex; + this.orientation.value = parse.data.orientation; } - if (beats.value.length === 0) { - resetActiveBeat(); + if (this.beats.value.length === 0) { + this.resetActiveBeat(); } - nextTick(() => saveDirty.value = false); + nextTick(() => this.saveDirtyGlobal.value = false); } - function bakeAll(): void { - activeBeat.value?.bakeLoops(); + bakeAll(): void { + this.activeBeat.value?.bakeLoops(); } - - watch([activeBeatIndex, orientation, beats], () => { - saveDirty.value = true; - }); - - const saveInterval = setInterval(() => saveDirty && save("localStorage"), 5 * 60 * 1000); - - onScopeDispose(() => clearInterval(saveInterval)); - - window.addEventListener("beforeunload", (e) => { - if (saveDirty.value) { - e.preventDefault(); - e.returnValue = true; - } - }); - - const savedItem = localStorage.getItem("drum-slayer-save"); - if (savedItem) { - const serial = JSON.parse(savedItem); - beats.value = [createDefaultMainBeatGroup(manager)]; - loadFromSave(serial); - } - - return { - beats, - activeBeatIndex, - activeBeat, - saveDirty, - orientation, - - save, - addNewBeat, - removeBeat, - resetActiveBeat, - bakeAll, - }; } -export type BeatStore = ReturnType; - -export const BeatStoreKey = Symbol("BeatStore") as InjectionKey; +const BeatStoreKey = Symbol("BeatStore") as InjectionKey; export function useBeatStore(): BeatStore { - return inject(BeatStoreKey, createBeatStore, true); + return inject(BeatStoreKey, () => { + const store = new BeatStore(); + getCurrentInstance()?.appContext?.app.provide(BeatStoreKey, store); + return store; + }, true); } diff --git a/src/EffectScoped.ts b/src/EffectScoped.ts deleted file mode 100644 index 783e529..0000000 --- a/src/EffectScoped.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { effectScope, type EffectScope } from "vue"; - -/** -* Creates a constructor for a class that may contain vue reactive primitives, setting up an effect scope to capture -* all effects, assigning a destroy function that will stop the effect scope and clean up effects. -* -* Wrapped classes containing reactive effects can be easily used without tying their lifetimes to a component instance -* or the global application. This is effectively the equivalent of heap allocation instead of stack allocation. -* -* Advantages: -* - less boilerplate (no need to remember to return all public methods and properties from a composable) -* - makes it possible to manange reactive lifetimes that are entirely independent of UI state, such as objects that -* are constantly instantiated and destroyed that manage their own reactive states, that need to be referenced -* globally by multiple parts of a complex UI. -* - The class syntax makes it easy to reference properites and functions before their declaration, avoiding annoying -* forward declarations. -*/ -export default class EffectScoped { - private effectScope?: EffectScope; - - /** - * Creates an effectscoped version of the class - */ - static asScoped EffectScoped>(this: C, ...args: ConstructorParameters): InstanceType { - const scope = effectScope(true); - const instance = scope.run(() => new this(...args)) ?? null; - if (instance === null) { - throw new Error(`Failed to instantiate class ${ this.constructor.name }.`); - } - instance.effectScope = scope; - return instance as InstanceType; - } - - onDestroy() {} - - destroy() { - this.onDestroy(); - this.effectScope?.stop(); - } -} - diff --git a/src/Track.ts b/src/Track.ts index 3c30005..1a5d00b 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -1,7 +1,6 @@ -import { isPosInt } from "@/utils"; +import { isPosInt, EffectScoped } from "@/utils"; import { ref, shallowRef, computed, watch, reactive, triggerRef, type Ref, type ShallowRef } from "vue"; import type { BeatManager } from "./Beat"; -import EffectScoped from "./EffectScoped"; import { z } from "zod"; export type TrackInitOptions = { @@ -58,10 +57,10 @@ export function deserialise(serial: unknown, manager: BeatManager) { })); return Track.asScoped({ manager, - barCount: serial.barCount, - isLooping: serial.looping, - loopLength: serial.loopLength, - name: serial.name, + barCount: beat.barCount, + isLooping: beat.looping, + loopLength: beat.loopLength, + name: beat.name, timeSig: { up: beat.timeSigUp, down: beat.timeSigDown, diff --git a/src/assets/svgs/floppy2-fill.svg b/src/assets/svgs/floppy2-fill.svg new file mode 100644 index 0000000..61a6cdb --- /dev/null +++ b/src/assets/svgs/floppy2-fill.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/svgs/upload.svg b/src/assets/svgs/upload.svg new file mode 100644 index 0000000..9a4a363 --- /dev/null +++ b/src/assets/svgs/upload.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 178d091..6aea46e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,3 +5,8 @@ import "@/ui/global.css"; const app = createApp(Root, { title: "Drum Slayer" }); app.mount("#app"); +interface Window { + drumslayer?: Record, +} + + diff --git a/src/ui/Beat/Beat.vue b/src/ui/Beat/Beat.vue index 00ba6a7..1f06dde 100644 --- a/src/ui/Beat/Beat.vue +++ b/src/ui/Beat/Beat.vue @@ -1,6 +1,6 @@