This commit is contained in:
2024-04-01 23:13:39 +02:00
parent 3c9065ee8c
commit 3cda9c3e96
14 changed files with 382 additions and 214 deletions

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
/public/static/*.js.map /public/static/*.js.map
/dist /dist
.idea .idea
.DS_STORE

View File

@@ -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 = export type UITool =
| "track-unit-type" | "track-unit-type"
| "eraser" | "eraser"
| "sticking"; | "sticking";
export function createAppStateStore() { export class AppStateStore extends Bound {
const selectedTool = ref<UITool>("track-unit-type"); selectedTool = ref<UITool>("track-unit-type");
const activeTrackUnitType = ref(0); activeTrackUnitType = ref(0);
const activeStickingType = ref(1); activeStickingType = ref(1);
const unitMouseStart = ref<string | null>(null); unitMouseStart = ref<string | null>(null);
const selectingUnits = ref(false); selectingUnits = ref(false);
const deselectingUnits = ref(false); deselectingUnits = ref(false);
return {
selectingUnits,
deselectingUnits,
selectedTool,
activeTrackUnitType,
activeStickingType,
unitMouseStart,
};
} }
const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey<AppStateStore>;
export type AppStateStore = ReturnType<typeof createAppStateStore>;
export const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey<AppStateStore>;
export function useAppStateStore(): AppStateStore { 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);
} }

View File

@@ -1,7 +1,6 @@
import { deserialise as deserialiseTrack, type TrackInitOptions, isValidTimeSigRange, Track } from "@/Track"; import { deserialise as deserialiseTrack, type TrackInitOptions, isValidTimeSigRange, Track } from "@/Track";
import { greatestCommonDivisor, isPosInt } from "@/utils"; import { greatestCommonDivisor, isPosInt, EffectScoped } from "@/utils";
import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef } from "vue"; import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef, nextTick } from "vue";
import EffectScoped from "./EffectScoped";
import { z } from "zod"; import { z } from "zod";
export interface BeatManager { export interface BeatManager {
@@ -9,7 +8,6 @@ export interface BeatManager {
} }
type BeatGroupInitOptions = { type BeatGroupInitOptions = {
manager: BeatManager,
barCount: number; barCount: number;
isLooping: boolean; isLooping: boolean;
timeSigUp: number; timeSigUp: number;
@@ -20,7 +18,7 @@ type BeatGroupInitOptions = {
}; };
const BeatSerialSchema = z.object({ const BeatSerialSchema = z.object({
tracks: z.array(z.any()), tracks: z.array(z.unknown()),
barCount: z.number(), barCount: z.number(),
timeSigUp: z.number(), timeSigUp: z.number(),
globalLoopLength: z.number(), globalLoopLength: z.number(),
@@ -32,9 +30,11 @@ const BeatSerialSchema = z.object({
export type BeatSerial = z.infer<typeof BeatSerialSchema>; export type BeatSerial = z.infer<typeof BeatSerialSchema>;
export class Beat extends EffectScoped { export class Beat extends EffectScoped {
private static count = 0;
private barCountInternal: Ref<number>; private barCountInternal: Ref<number>;
private globalLoopLengthInternal: Ref<number>; private globalLoopLengthInternal: Ref<number>;
manager: BeatManager; readonly id = Beat.count++;
tracks: ShallowRef<Track[]>; tracks: ShallowRef<Track[]>;
barCount: WritableComputedRef<number>; barCount: WritableComputedRef<number>;
timeSigUp: Ref<number>; timeSigUp: Ref<number>;
@@ -43,10 +43,10 @@ export class Beat extends EffectScoped {
useAutoBeatLength: Ref<boolean>; useAutoBeatLength: Ref<boolean>;
barSettingsLocked: Ref<boolean>; barSettingsLocked: Ref<boolean>;
name: Ref<string>; name: Ref<string>;
saveDirty = ref(false);
constructor(opts: BeatGroupInitOptions) { constructor(opts: BeatGroupInitOptions) {
super(); super();
this.manager = opts.manager;
this.tracks = shallowRef<Track[]>([]); this.tracks = shallowRef<Track[]>([]);
this.barCountInternal = ref<number>(opts.barCount ?? 4); this.barCountInternal = ref<number>(opts.barCount ?? 4);
this.barCount = computed({ this.barCount = computed({
@@ -89,14 +89,17 @@ export class Beat extends EffectScoped {
this.globalIsLooping = ref<boolean>(opts.isLooping ?? false); this.globalIsLooping = ref<boolean>(opts.isLooping ?? false);
this.useAutoBeatLength = ref(opts.useAutoBeatLength ?? false); this.useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
this.barSettingsLocked = computed(() => this.useAutoBeatLength.value); 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) { for (const track of this.tracks.value) {
track.barCount.value = newBarCount; track.barCount.value = newBarCount;
track.timeSigUp.value = newTimeSigUp; track.timeSigUp.value = newTimeSigUp;
} }
this.saveDirty.value = true;
}, { immediate: true }); }, { immediate: true });
nextTick(() => this.saveDirty.value = false);
} }
setTimeSigUp(timeSigVal: number): void { setTimeSigUp(timeSigVal: number): void {
@@ -124,9 +127,13 @@ export class Beat extends EffectScoped {
triggerRef(this.tracks); triggerRef(this.tracks);
} }
notifyChange() {
this.saveDirty.value = true;
}
addTrack(options?: Omit<TrackInitOptions, 'manager'>): Track | null { addTrack(options?: Omit<TrackInitOptions, 'manager'>): Track | null {
const optionsResolved = { const optionsResolved = {
manager: this.manager, manager: this,
barCount: this.barCount.value, barCount: this.barCount.value,
isLooping: this.globalIsLooping.value, isLooping: this.globalIsLooping.value,
loopLength: this.globalLoopLength.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); const parse = BeatSerialSchema.safeParse(serial);
if (!parse.success) { if (!parse.success) {
console.error('Encountered invalid beat serial:', parse.error, serial); 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 beat = parse.data;
const newBeat = Beat.asScoped({ const newBeat = Beat.asScoped({
manager,
loopLength: beat.globalLoopLength, loopLength: beat.globalLoopLength,
barCount: beat.barCount, barCount: beat.barCount,
isLooping: beat.globalIsLooping, isLooping: beat.globalIsLooping,
@@ -190,7 +196,7 @@ export function deserialise(serial: unknown, manager: BeatManager): Beat | null
useAutoBeatLength: beat.useAutoBeatLength, useAutoBeatLength: beat.useAutoBeatLength,
}); });
beat.tracks.forEach(trackSerial => { beat.tracks.forEach(trackSerial => {
const track = deserialiseTrack(trackSerial, manager); const track = deserialiseTrack(trackSerial, newBeat);
if (track) newBeat.tracks.value.push(track); if (track) newBeat.tracks.value.push(track);
}); });
return newBeat; return newBeat;

View File

@@ -1,6 +1,7 @@
import { Beat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat"; 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 { z } from "zod";
import { Bound } from "./utils";
export const DrumSlayerSaveSchema = z.object({ export const DrumSlayerSaveSchema = z.object({
orientation: z.union([z.literal("horizontal"), z.literal("vertical")]), orientation: z.union([z.literal("horizontal"), z.literal("vertical")]),
@@ -9,9 +10,8 @@ export const DrumSlayerSaveSchema = z.object({
}); });
export type DrumSlayerSave = z.infer<typeof DrumSlayerSaveSchema>; export type DrumSlayerSave = z.infer<typeof DrumSlayerSaveSchema>;
function createDefaultMainBeatGroup(manager: BeatManager): Beat { function createDefaultMainBeatGroup(): Beat {
const defaultSettings = { const defaultSettings = {
manager,
barCount: 2, barCount: 2,
isLooping: false, isLooping: false,
timeSigUp: 8, timeSigUp: 8,
@@ -24,117 +24,123 @@ function createDefaultMainBeatGroup(manager: BeatManager): Beat {
return mainBeatGroup; return mainBeatGroup;
} }
export function createBeatStore() { export class BeatStore extends Bound {
const saveDirty = ref(false); private saveDirtyGlobal = ref(false);
beats = shallowRef<Beat[]>([ createDefaultMainBeatGroup() ]);
saveDirty = computed(() => this.saveDirtyGlobal.value || this.beats.value.reduce((last, beat) => beat.saveDirty.value || last, false));
activeBeatIndex = ref(0);
activeBeat = computed<Beat | null>(() => this.beats.value[this.activeBeatIndex.value] ?? null);
orientation = ref<"horizontal" | "vertical">("horizontal");
const manager = { constructor() {
notifyChange() { super();
saveDirty.value = true; watch([this.activeBeatIndex, this.orientation, this.beats], () => {
}, this.saveDirtyGlobal.value = true;
}; });
const beats = shallowRef<Beat[]>([ createDefaultMainBeatGroup(manager) ]); const saveInterval = setInterval(() => this.saveDirtyGlobal.value && this.save("localStorage"), 5 * 60 * 1000);
const activeBeatIndex = ref(0);
const activeBeat = computed<Beat | null>(() => beats.value[activeBeatIndex.value] ?? null);
const orientation = ref<"horizontal" | "vertical">("horizontal");
function resetActiveBeat(): void { onScopeDispose(() => clearInterval(saveInterval));
const current = activeBeat.value;
beats.value[activeBeatIndex.value] = createDefaultMainBeatGroup(manager); 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(); current?.destroy();
triggerRef(beats); triggerRef(this.beats);
} }
function removeBeat(index: number): void { removeBeat(index: number): void {
const beat = beats.value[index]; const beat = this.beats.value[index];
beats.value.splice(index, 1); this.beats.value.splice(index, 1);
beat?.destroy(); if (this.activeBeatIndex.value === index) {
triggerRef(beats); 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 { addNewBeat(config?: unknown): number {
const newBeat = createDefaultMainBeatGroup(manager); let newBeat: Beat | null = null;
const newIndex = beats.value.push(newBeat); if (config) {
triggerRef(beats); 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; return newIndex - 1;
} }
function save(destination: "localStorage"): void { save(destination: "localStorage"): void {
if (destination === "localStorage") { 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({ localStorage.setItem("drum-slayer-save", JSON.stringify({
beats: serials, beats: serials,
activeBeatIndex: activeBeatIndex.value, activeBeatIndex: this.activeBeatIndex.value,
orientation: orientation.value ?? "horizontal", orientation: this.orientation.value ?? "horizontal",
} satisfies DrumSlayerSave)); } 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 { loadFromSave(source: unknown): void {
beats.value.length = 0; this.beats.value.length = 0;
const parse = DrumSlayerSaveSchema.safeParse(source); const parse = DrumSlayerSaveSchema.safeParse(source);
if (parse.success) { if (parse.success) {
parse.data.beats.forEach((beat: unknown) => { parse.data.beats.forEach((beat: unknown) => {
const deserialisedBeat = deserialiseBeat(beat, manager); const deserialisedBeat = deserialiseBeat(beat);
if (deserialisedBeat) { if (deserialisedBeat) {
beats.value.push(deserialisedBeat); this.beats.value.push(deserialisedBeat);
} }
}); });
activeBeatIndex.value = parse.data.activeBeatIndex; this.activeBeatIndex.value = parse.data.activeBeatIndex;
orientation.value = parse.data.orientation; this.orientation.value = parse.data.orientation;
} }
if (beats.value.length === 0) { if (this.beats.value.length === 0) {
resetActiveBeat(); this.resetActiveBeat();
} }
nextTick(() => saveDirty.value = false); nextTick(() => this.saveDirtyGlobal.value = false);
} }
function bakeAll(): void { bakeAll(): void {
activeBeat.value?.bakeLoops(); 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<typeof createBeatStore>; const BeatStoreKey = Symbol("BeatStore") as InjectionKey<BeatStore>;
export const BeatStoreKey = Symbol("BeatStore") as InjectionKey<BeatStore>;
export function useBeatStore(): BeatStore { 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);
} }

View File

@@ -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<C extends new(...args: any[]) => EffectScoped>(this: C, ...args: ConstructorParameters<C>): InstanceType<C> {
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<C>;
}
onDestroy() {}
destroy() {
this.onDestroy();
this.effectScope?.stop();
}
}

View File

@@ -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 { ref, shallowRef, computed, watch, reactive, triggerRef, type Ref, type ShallowRef } from "vue";
import type { BeatManager } from "./Beat"; import type { BeatManager } from "./Beat";
import EffectScoped from "./EffectScoped";
import { z } from "zod"; import { z } from "zod";
export type TrackInitOptions = { export type TrackInitOptions = {
@@ -58,10 +57,10 @@ export function deserialise(serial: unknown, manager: BeatManager) {
})); }));
return Track.asScoped({ return Track.asScoped({
manager, manager,
barCount: serial.barCount, barCount: beat.barCount,
isLooping: serial.looping, isLooping: beat.looping,
loopLength: serial.loopLength, loopLength: beat.loopLength,
name: serial.name, name: beat.name,
timeSig: { timeSig: {
up: beat.timeSigUp, up: beat.timeSigUp,
down: beat.timeSigDown, down: beat.timeSigDown,

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-floppy2-fill" viewBox="0 0 16 16">
<path d="M12 2h-2v3h2z"/>
<path d="M1.5 0A1.5 1.5 0 0 0 0 1.5v13A1.5 1.5 0 0 0 1.5 16h13a1.5 1.5 0 0 0 1.5-1.5V2.914a1.5 1.5 0 0 0-.44-1.06L14.147.439A1.5 1.5 0 0 0 13.086 0zM4 6a1 1 0 0 1-1-1V1h10v4a1 1 0 0 1-1 1zM3 9h10a1 1 0 0 1 1 1v5H2v-5a1 1 0 0 1 1-1"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-upload" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

@@ -5,3 +5,8 @@ import "@/ui/global.css";
const app = createApp(Root, { title: "Drum Slayer" }); const app = createApp(Root, { title: "Drum Slayer" });
app.mount("#app"); app.mount("#app");
interface Window {
drumslayer?: Record<string, any>,
}

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="beat" :class="{ vertical: false }"> <div v-if="beat" class="beat" :class="{ vertical: false }">
<editable-text-field node-type="h3" class="beat-title" v-model="beat!.name.value" /> <editable-text-field node-type="h3" class="beat-title" v-model="beat.name.value" />
<div class="beat-main-container"> <div class="beat-main-container">
<div class="beat-track-container" :class="{ dragging }"> <div class="beat-track-container" :class="{ dragging }">
<draggable @start="dragging = true" <draggable @start="dragging = true"

View File

@@ -2,20 +2,20 @@
<div class="beat-settings" v-if="beat"> <div class="beat-settings" v-if="beat">
<div class="beat-settings-options"> <div class="beat-settings-options">
<div class="beat-settings-boxes beat-settings-option"> <div class="beat-settings-boxes beat-settings-option">
<number-input label="Bars: " v-model="beat!.barCount.value" :disabled="beat!.barSettingsLocked.value" /> <number-input label="Bars: " v-model="beat.barCount.value" :disabled="beat.barSettingsLocked.value" />
</div> </div>
<div class="beat-settings-bar-count beat-settings-option"> <div class="beat-settings-bar-count beat-settings-option">
<number-input label="Boxes per bar: " v-model="beat!.timeSigUp.value" /> <number-input label="Boxes per bar: " v-model="beat.timeSigUp.value" />
</div> </div>
<div class="beat-settings-bar-count beat-settings-option"> <div class="beat-settings-bar-count beat-settings-option">
<bool-box label="Auto beat length: " v-model="beat!.useAutoBeatLength.value" /> <bool-box label="Auto beat length: " v-model="beat.useAutoBeatLength.value" />
</div> </div>
<action-button <action-button
label="New Track" label="New Track"
@click="() => beat!.addTrack()" /> @click="() => beat?.addTrack()" />
<div> <div>
<track-settings <track-settings
v-for="(_, i) in beat!.tracks.value ?? []" v-for="(_, i) in beat.tracks.value ?? []"
:key="i" :key="i"
:beat-index="beatIndex" :beat-index="beatIndex"
:track-index="i" /> :track-index="i" />

View File

@@ -4,16 +4,32 @@
:class="{ 'sidebar-visible': sidebarActive, 'vertical-mode': currentOrientation === 'vertical' }"> :class="{ 'sidebar-visible': sidebarActive, 'vertical-mode': currentOrientation === 'vertical' }">
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-left-strip"> <div class="sidebar-left-strip">
<div v-for="(beat, i) in beats" <draggable animation="150"
:key="beat.name.value" @start="onStartDragBeatTab"
class="sidebar-left-tab" @end="onEndDragBeatTab"
:class="{ 'active': i === activeBeatIndex }" v-model="beats"
@click="activeBeatIndex = i"> ghost-class="ghost"
{{ beat.name.value }} itemKey="name.value">
</div> <template #item="{ element, index }">
<div
:key="element.name.value"
class="sidebar-left-tab"
:class="{ 'active': index === activeBeatIndex }"
@click="activeBeatIndex = index">
<span v-if="element.saveDirty.value" class="unsaved"></span>
<span class="name">{{ element.name.value }}</span>
<div
class="delete"
@click="onDeleteBeat(index)"
title="delete">
<div class="x">×</div>
</div>
</div>
</template>
</draggable>
<div <div
class="sidebar-add-beat" class="tab-add"
@click="onAddNewBeat()"> @click="onAddNewBeat">
+ +
</div> </div>
</div> </div>
@@ -50,10 +66,22 @@
<div <div
class="quick-access-button" class="quick-access-button"
:class="{ 'unclickable': !saveDirty }" :class="{ 'unclickable': !saveDirty }"
:title="saveDirty ? 'Save changes' : 'No unsaved changes'" :title="saveDirty ? 'Save all changes' : 'No unsaved changes'"
@click="save('localStorage')"> @click="save('localStorage')">
<icon icon-name="save" color="var(--color-ui-neutral-dark)" />
</div>
<div
class="quick-access-button"
title="Save current beat to file"
@click="saveCurrentBeatToFile">
<icon icon-name="download" color="var(--color-ui-neutral-dark)" /> <icon icon-name="download" color="var(--color-ui-neutral-dark)" />
</div> </div>
<div
class="quick-access-button"
title="Save current beat to file"
@click="uploadDialog?.showModal()">
<icon icon-name="upload" color="var(--color-ui-neutral-dark)" />
</div>
</div> </div>
<toolbox class="toolbox" /> <toolbox class="toolbox" />
</div> </div>
@@ -66,17 +94,24 @@
:orientation="currentOrientation" /> :orientation="currentOrientation" />
</div> </div>
</div> </div>
<dialog ref="uploadDialog" class="upload-dialog">
<h2>Upload a Drum Slayer file</h2>
<input id="upload-file" type="file" @change="onFileInputChange">
<button @click="onUpload" :disabled="!canUpload">Upload</button>
<button @click="uploadDialog?.close()">Cancel</button>
</dialog>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onBeforeUnmount, onMounted, provide, ref, watch } from "vue"; import { onBeforeUnmount, onMounted, ref, watch } from "vue";
import BeatSettings from "@/ui/BeatSettings/BeatSettings.vue"; import BeatSettings from "@/ui/BeatSettings/BeatSettings.vue";
import BeatView from "@/ui/Beat/Beat.vue"; import BeatView from "@/ui/Beat/Beat.vue";
import Icon from "@/ui/Widgets/Icon/Icon.vue"; import Icon from "@/ui/Widgets/Icon/Icon.vue";
import Toolbox from "@/ui/Root/Toolbox.vue"; import Toolbox from "@/ui/Root/Toolbox.vue";
import { BeatStoreKey, createBeatStore } from "@/BeatStore"; import { useBeatStore } from "@/BeatStore";
import { AppStateStoreKey, createAppStateStore } from "@/AppState"; import { useAppStateStore } from "@/AppState";
import Draggable from "vuedraggable";
const TITLE = 'Drum Slayer'; const TITLE = 'Drum Slayer';
@@ -87,26 +122,89 @@
const currentOrientation = ref<'horizontal' | 'vertical'>('horizontal'); const currentOrientation = ref<'horizontal' | 'vertical'>('horizontal');
const sidebarActive = ref(false); const sidebarActive = ref(false);
const appStateStore = createAppStateStore(); const appStateStore = useAppStateStore();
provide(AppStateStoreKey, appStateStore); const beatStore = useBeatStore();
const beatStore = createBeatStore(); window.drumslayer = {
provide(BeatStoreKey, beatStore); appState: appStateStore,
beatStore,
};
const { const {
save, save,
resetActiveBeat, resetActiveBeat,
activeBeatIndex, activeBeatIndex,
activeBeat,
beats, beats,
addNewBeat, addNewBeat,
bakeAll, bakeAll,
saveDirty, saveDirty,
removeBeat,
} = beatStore; } = beatStore;
function onAddNewBeat() { function onAddNewBeat() {
activeBeatIndex.value = addNewBeat(); activeBeatIndex.value = addNewBeat();
} }
let lastActiveBeatIndex: number | null = null;
function onStartDragBeatTab() {
lastActiveBeatIndex = activeBeat.value?.id ?? null;
}
function onEndDragBeatTab() {
if (lastActiveBeatIndex !== null) {
activeBeatIndex.value = beats.value.findIndex(beat => beat.id === lastActiveBeatIndex) ?? lastActiveBeatIndex;
}
}
function onDeleteBeat(index: number) {
const beatToDelete = beats.value[index];
if (beatToDelete) {
if (confirm('ARE YOU SURE?')) {
removeBeat(index);
}
}
}
const uploadErr = ref(false);
const canUpload = ref(false);
const uploadDialog = ref<HTMLDialogElement | null>(null);
function onFileInputChange(event: Event) {
const inputEl = event.currentTarget as HTMLInputElement;
canUpload.value = !!inputEl?.files?.length;
}
async function onUpload() {
const input = document.getElementById('upload-file') as HTMLInputElement;
if (input.files) {
const file = input.files.item(0);
const text = await file?.text() ?? null;
if (text) {
try {
const parsed = JSON.parse(text);
const newBeatIndex = addNewBeat(parsed);
if (newBeatIndex === -1) {
uploadErr.value = true;
}
} catch (e) {
}
}
}
uploadDialog.value?.close();
}
function saveCurrentBeatToFile() {
if (activeBeat.value) {
const serial = activeBeat.value.serialise();
const a = document.createElement("a");
const file = new Blob([JSON.stringify(serial)], { type: 'text' });
a.href = URL.createObjectURL(file);
a.download = `${ activeBeat.value.name.value }.drms`;
a.click();
}
}
watch(saveDirty, (dirty) => { watch(saveDirty, (dirty) => {
if (dirty) { if (dirty) {
document.title = `${ TITLE } (unsaved changes)`; document.title = `${ TITLE } (unsaved changes)`;
@@ -251,35 +349,65 @@
.sidebar-left-strip { .sidebar-left-strip {
writing-mode: vertical-rl; writing-mode: vertical-rl;
background-color: var(--color-bg-light);
}
.sidebar-left-strip > * {
display: inline-block;
}
.sidebar-left-tab {
transform: rotate(-180deg);
display: inline-block;
width: 100%;
padding: 8px 3px 8px 3px;
}
.sidebar-left-tab.active {
background-color: var(--color-bg-medium); background-color: var(--color-bg-medium);
> * {
display: inline-block;
}
}
.sidebar-left-tab, .tab-add {
display: inline-block; display: inline-block;
}
.sidebar-add-beat {
width: 100%; width: 100%;
padding: 8px 3px 8px 3px; padding: 8px 3px 24px 3px;
position: relative;
.unsaved {
margin-bottom: 5px;
opacity: 50%;
}
.delete {
position: absolute;
bottom: 5px;
left: 6px;
width: 15px;
height: 15px;
display: inline-block;
color: var(--color-ui-neutral-dark);
border-radius: 2px;
cursor: pointer;
&:hover {
filter: brightness(1.5);
}
.x {
position: absolute;
right: -3px;
bottom: -3px;
}
}
&.active {
background-color: var(--color-bg-light);
display: inline-block;
}
&:hover:not(.active) {
cursor: pointer;
background-color: var(--color-ui-neutral-dark);
transition: background-color 200ms;
}
&:hover {
.delete {
filter: brightness(1.2);
}
}
} }
.sidebar-add-beat:hover, .tab-name {
.sidebar-left-tab:hover:not(.active) { display: inline-block;
cursor: pointer;
background-color: var(--color-ui-neutral-dark);
transition: background-color 200ms;
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
@@ -310,5 +438,9 @@
user-select: none; user-select: none;
} }
} }
.upload-dialog {
z-index: 20;
}
</style> </style>

View File

@@ -8,6 +8,8 @@ import RightHand from "@/assets/svgs/RH.png";
import LeftFoot from "@/assets/svgs/LF.png"; import LeftFoot from "@/assets/svgs/LF.png";
import RightFoot from "@/assets/svgs/RF.png"; import RightFoot from "@/assets/svgs/RF.png";
import Eraser from "@/assets/svgs/eraser-fill.svg"; import Eraser from "@/assets/svgs/eraser-fill.svg";
import Upload from "@/assets/svgs/upload.svg";
import Floppy from "@/assets/svgs/floppy2-fill.svg";
export const IconUrlMap = { export const IconUrlMap = {
arrowClockwise: ArrowClockwise, arrowClockwise: ArrowClockwise,
@@ -20,6 +22,8 @@ export const IconUrlMap = {
lf: LeftFoot, lf: LeftFoot,
rf: RightFoot, rf: RightFoot,
eraser: Eraser, eraser: Eraser,
upload: Upload,
save: Floppy,
} as const; } as const;
export type IconName = keyof typeof IconUrlMap; export type IconName = keyof typeof IconUrlMap;

View File

@@ -1,3 +1,5 @@
import { effectScope, type EffectScope } from "vue";
export function isPosInt(maybePosInt: number): boolean { export function isPosInt(maybePosInt: number): boolean {
return (maybePosInt | 0) === maybePosInt && maybePosInt > 0; return (maybePosInt | 0) === maybePosInt && maybePosInt > 0;
} }
@@ -9,4 +11,57 @@ export function greatestCommonDivisor(a: number, b: number): number {
a = temp; a = temp;
} }
return a; return a;
} }
export class Bound {
constructor() {
for (const propertyKey of Object.getOwnPropertyNames(Object.getPrototypeOf(this))) {
// @ts-ignore
const method = this[propertyKey];
if (method instanceof Function) {
// @ts-ignore
this[propertyKey] = (method as Function).bind(this);
}
}
}
}
/**
* 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 class EffectScoped {
private effectScope?: EffectScope;
/**
* Creates an effectscoped version of the class
*/
static asScoped<C extends new(...args: any[]) => EffectScoped>(this: C, ...args: ConstructorParameters<C>): InstanceType<C> {
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<C>;
}
onDestroy() {}
destroy() {
this.onDestroy();
this.effectScope?.stop();
}
}