update
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
||||
/public/static/*.js.map
|
||||
/dist
|
||||
.idea
|
||||
.DS_STORE
|
||||
|
||||
@@ -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<UITool>("track-unit-type");
|
||||
const activeTrackUnitType = ref(0);
|
||||
const activeStickingType = ref(1);
|
||||
const unitMouseStart = ref<string | null>(null);
|
||||
const selectingUnits = ref(false);
|
||||
const deselectingUnits = ref(false);
|
||||
|
||||
return {
|
||||
selectingUnits,
|
||||
deselectingUnits,
|
||||
selectedTool,
|
||||
activeTrackUnitType,
|
||||
activeStickingType,
|
||||
unitMouseStart,
|
||||
};
|
||||
export class AppStateStore extends Bound {
|
||||
selectedTool = ref<UITool>("track-unit-type");
|
||||
activeTrackUnitType = ref(0);
|
||||
activeStickingType = ref(1);
|
||||
unitMouseStart = ref<string | null>(null);
|
||||
selectingUnits = ref(false);
|
||||
deselectingUnits = ref(false);
|
||||
}
|
||||
|
||||
|
||||
export type AppStateStore = ReturnType<typeof createAppStateStore>;
|
||||
|
||||
export const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey<AppStateStore>;
|
||||
const AppStateStoreKey = Symbol("AppStateStore") as InjectionKey<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);
|
||||
}
|
||||
|
||||
32
src/Beat.ts
32
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<typeof BeatSerialSchema>;
|
||||
|
||||
export class Beat extends EffectScoped {
|
||||
private static count = 0;
|
||||
private barCountInternal: Ref<number>;
|
||||
private globalLoopLengthInternal: Ref<number>;
|
||||
manager: BeatManager;
|
||||
readonly id = Beat.count++;
|
||||
|
||||
tracks: ShallowRef<Track[]>;
|
||||
barCount: WritableComputedRef<number>;
|
||||
timeSigUp: Ref<number>;
|
||||
@@ -43,10 +43,10 @@ export class Beat extends EffectScoped {
|
||||
useAutoBeatLength: Ref<boolean>;
|
||||
barSettingsLocked: Ref<boolean>;
|
||||
name: Ref<string>;
|
||||
saveDirty = ref(false);
|
||||
|
||||
constructor(opts: BeatGroupInitOptions) {
|
||||
super();
|
||||
this.manager = opts.manager;
|
||||
this.tracks = shallowRef<Track[]>([]);
|
||||
this.barCountInternal = ref<number>(opts.barCount ?? 4);
|
||||
this.barCount = computed({
|
||||
@@ -89,14 +89,17 @@ export class Beat extends EffectScoped {
|
||||
this.globalIsLooping = ref<boolean>(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<TrackInitOptions, 'manager'>): 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;
|
||||
|
||||
194
src/BeatStore.ts
194
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<typeof DrumSlayerSaveSchema>;
|
||||
|
||||
function createDefaultMainBeatGroup(manager: BeatManager): Beat {
|
||||
function createDefaultMainBeatGroup(): Beat {
|
||||
const defaultSettings = {
|
||||
manager,
|
||||
barCount: 2,
|
||||
isLooping: false,
|
||||
timeSigUp: 8,
|
||||
@@ -24,86 +24,26 @@ 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<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 = {
|
||||
notifyChange() {
|
||||
saveDirty.value = true;
|
||||
},
|
||||
};
|
||||
|
||||
const beats = shallowRef<Beat[]>([ createDefaultMainBeatGroup(manager) ]);
|
||||
const activeBeatIndex = ref(0);
|
||||
const activeBeat = computed<Beat | null>(() => beats.value[activeBeatIndex.value] ?? null);
|
||||
const orientation = ref<"horizontal" | "vertical">("horizontal");
|
||||
|
||||
function resetActiveBeat(): void {
|
||||
const current = activeBeat.value;
|
||||
beats.value[activeBeatIndex.value] = createDefaultMainBeatGroup(manager);
|
||||
current?.destroy();
|
||||
triggerRef(beats);
|
||||
}
|
||||
|
||||
function removeBeat(index: number): void {
|
||||
const beat = beats.value[index];
|
||||
beats.value.splice(index, 1);
|
||||
beat?.destroy();
|
||||
triggerRef(beats);
|
||||
}
|
||||
|
||||
function addNewBeat(): number {
|
||||
const newBeat = createDefaultMainBeatGroup(manager);
|
||||
const newIndex = beats.value.push(newBeat);
|
||||
triggerRef(beats);
|
||||
return newIndex - 1;
|
||||
}
|
||||
|
||||
function save(destination: "localStorage"): void {
|
||||
if (destination === "localStorage") {
|
||||
const serials = beats.value.map(beat => beat.serialise());
|
||||
localStorage.setItem("drum-slayer-save", JSON.stringify({
|
||||
beats: serials,
|
||||
activeBeatIndex: activeBeatIndex.value,
|
||||
orientation: orientation.value ?? "horizontal",
|
||||
} satisfies DrumSlayerSave));
|
||||
saveDirty.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loadFromSave(source: unknown): void {
|
||||
beats.value.length = 0;
|
||||
const parse = DrumSlayerSaveSchema.safeParse(source);
|
||||
if (parse.success) {
|
||||
parse.data.beats.forEach((beat: unknown) => {
|
||||
const deserialisedBeat = deserialiseBeat(beat, manager);
|
||||
if (deserialisedBeat) {
|
||||
beats.value.push(deserialisedBeat);
|
||||
}
|
||||
});
|
||||
activeBeatIndex.value = parse.data.activeBeatIndex;
|
||||
orientation.value = parse.data.orientation;
|
||||
}
|
||||
if (beats.value.length === 0) {
|
||||
resetActiveBeat();
|
||||
}
|
||||
nextTick(() => saveDirty.value = false);
|
||||
}
|
||||
|
||||
function bakeAll(): void {
|
||||
activeBeat.value?.bakeLoops();
|
||||
}
|
||||
|
||||
watch([activeBeatIndex, orientation, beats], () => {
|
||||
saveDirty.value = true;
|
||||
constructor() {
|
||||
super();
|
||||
watch([this.activeBeatIndex, this.orientation, this.beats], () => {
|
||||
this.saveDirtyGlobal.value = true;
|
||||
});
|
||||
|
||||
const saveInterval = setInterval(() => saveDirty && save("localStorage"), 5 * 60 * 1000);
|
||||
const saveInterval = setInterval(() => this.saveDirtyGlobal.value && this.save("localStorage"), 5 * 60 * 1000);
|
||||
|
||||
onScopeDispose(() => clearInterval(saveInterval));
|
||||
|
||||
window.addEventListener("beforeunload", (e) => {
|
||||
if (saveDirty.value) {
|
||||
if (this.saveDirty.value) {
|
||||
e.preventDefault();
|
||||
e.returnValue = true;
|
||||
}
|
||||
@@ -112,29 +52,95 @@ export function createBeatStore() {
|
||||
const savedItem = localStorage.getItem("drum-slayer-save");
|
||||
if (savedItem) {
|
||||
const serial = JSON.parse(savedItem);
|
||||
beats.value = [createDefaultMainBeatGroup(manager)];
|
||||
loadFromSave(serial);
|
||||
this.beats.value = [createDefaultMainBeatGroup()];
|
||||
this.loadFromSave(serial);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
beats,
|
||||
activeBeatIndex,
|
||||
activeBeat,
|
||||
saveDirty,
|
||||
orientation,
|
||||
resetActiveBeat(): void {
|
||||
const current = this.activeBeat.value;
|
||||
this.beats.value[this.activeBeatIndex.value] = createDefaultMainBeatGroup();
|
||||
current?.destroy();
|
||||
triggerRef(this.beats);
|
||||
}
|
||||
|
||||
save,
|
||||
addNewBeat,
|
||||
removeBeat,
|
||||
resetActiveBeat,
|
||||
bakeAll,
|
||||
};
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
save(destination: "localStorage"): void {
|
||||
if (destination === "localStorage") {
|
||||
const serials = this.beats.value.map(beat => beat.serialise());
|
||||
localStorage.setItem("drum-slayer-save", JSON.stringify({
|
||||
beats: serials,
|
||||
activeBeatIndex: this.activeBeatIndex.value,
|
||||
orientation: this.orientation.value ?? "horizontal",
|
||||
} satisfies DrumSlayerSave));
|
||||
for (const beat of this.beats.value) {
|
||||
beat.saveDirty.value = false;
|
||||
}
|
||||
this.saveDirtyGlobal.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (deserialisedBeat) {
|
||||
this.beats.value.push(deserialisedBeat);
|
||||
}
|
||||
});
|
||||
this.activeBeatIndex.value = parse.data.activeBeatIndex;
|
||||
this.orientation.value = parse.data.orientation;
|
||||
}
|
||||
if (this.beats.value.length === 0) {
|
||||
this.resetActiveBeat();
|
||||
}
|
||||
nextTick(() => this.saveDirtyGlobal.value = false);
|
||||
}
|
||||
|
||||
bakeAll(): void {
|
||||
this.activeBeat.value?.bakeLoops();
|
||||
}
|
||||
}
|
||||
|
||||
export type BeatStore = ReturnType<typeof createBeatStore>;
|
||||
|
||||
export const BeatStoreKey = Symbol("BeatStore") as InjectionKey<BeatStore>;
|
||||
const BeatStoreKey = Symbol("BeatStore") as InjectionKey<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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
11
src/Track.ts
11
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,
|
||||
|
||||
4
src/assets/svgs/floppy2-fill.svg
Normal file
4
src/assets/svgs/floppy2-fill.svg
Normal 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 |
4
src/assets/svgs/upload.svg
Normal file
4
src/assets/svgs/upload.svg
Normal 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 |
@@ -5,3 +5,8 @@ import "@/ui/global.css";
|
||||
const app = createApp(Root, { title: "Drum Slayer" });
|
||||
app.mount("#app");
|
||||
|
||||
interface Window {
|
||||
drumslayer?: Record<string, any>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="beat" :class="{ vertical: false }">
|
||||
<editable-text-field node-type="h3" class="beat-title" v-model="beat!.name.value" />
|
||||
<div v-if="beat" class="beat" :class="{ vertical: false }">
|
||||
<editable-text-field node-type="h3" class="beat-title" v-model="beat.name.value" />
|
||||
<div class="beat-main-container">
|
||||
<div class="beat-track-container" :class="{ dragging }">
|
||||
<draggable @start="dragging = true"
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
<div class="beat-settings" v-if="beat">
|
||||
<div class="beat-settings-options">
|
||||
<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 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 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>
|
||||
<action-button
|
||||
label="New Track"
|
||||
@click="() => beat!.addTrack()" />
|
||||
@click="() => beat?.addTrack()" />
|
||||
<div>
|
||||
<track-settings
|
||||
v-for="(_, i) in beat!.tracks.value ?? []"
|
||||
v-for="(_, i) in beat.tracks.value ?? []"
|
||||
:key="i"
|
||||
:beat-index="beatIndex"
|
||||
:track-index="i" />
|
||||
|
||||
@@ -4,16 +4,32 @@
|
||||
:class="{ 'sidebar-visible': sidebarActive, 'vertical-mode': currentOrientation === 'vertical' }">
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-left-strip">
|
||||
<div v-for="(beat, i) in beats"
|
||||
:key="beat.name.value"
|
||||
class="sidebar-left-tab"
|
||||
:class="{ 'active': i === activeBeatIndex }"
|
||||
@click="activeBeatIndex = i">
|
||||
{{ beat.name.value }}
|
||||
</div>
|
||||
<draggable animation="150"
|
||||
@start="onStartDragBeatTab"
|
||||
@end="onEndDragBeatTab"
|
||||
v-model="beats"
|
||||
ghost-class="ghost"
|
||||
itemKey="name.value">
|
||||
<template #item="{ element, index }">
|
||||
<div
|
||||
class="sidebar-add-beat"
|
||||
@click="onAddNewBeat()">
|
||||
: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
|
||||
class="tab-add"
|
||||
@click="onAddNewBeat">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
@@ -50,10 +66,22 @@
|
||||
<div
|
||||
class="quick-access-button"
|
||||
:class="{ 'unclickable': !saveDirty }"
|
||||
:title="saveDirty ? 'Save changes' : 'No unsaved changes'"
|
||||
:title="saveDirty ? 'Save all changes' : 'No unsaved changes'"
|
||||
@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)" />
|
||||
</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>
|
||||
<toolbox class="toolbox" />
|
||||
</div>
|
||||
@@ -66,17 +94,24 @@
|
||||
:orientation="currentOrientation" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<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 BeatView from "@/ui/Beat/Beat.vue";
|
||||
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||
import Toolbox from "@/ui/Root/Toolbox.vue";
|
||||
import { BeatStoreKey, createBeatStore } from "@/BeatStore";
|
||||
import { AppStateStoreKey, createAppStateStore } from "@/AppState";
|
||||
import { useBeatStore } from "@/BeatStore";
|
||||
import { useAppStateStore } from "@/AppState";
|
||||
import Draggable from "vuedraggable";
|
||||
|
||||
const TITLE = 'Drum Slayer';
|
||||
|
||||
@@ -87,26 +122,89 @@
|
||||
const currentOrientation = ref<'horizontal' | 'vertical'>('horizontal');
|
||||
const sidebarActive = ref(false);
|
||||
|
||||
const appStateStore = createAppStateStore();
|
||||
provide(AppStateStoreKey, appStateStore);
|
||||
const appStateStore = useAppStateStore();
|
||||
const beatStore = useBeatStore();
|
||||
|
||||
const beatStore = createBeatStore();
|
||||
provide(BeatStoreKey, beatStore);
|
||||
window.drumslayer = {
|
||||
appState: appStateStore,
|
||||
beatStore,
|
||||
};
|
||||
|
||||
const {
|
||||
save,
|
||||
resetActiveBeat,
|
||||
activeBeatIndex,
|
||||
activeBeat,
|
||||
beats,
|
||||
addNewBeat,
|
||||
bakeAll,
|
||||
saveDirty,
|
||||
removeBeat,
|
||||
} = beatStore;
|
||||
|
||||
function onAddNewBeat() {
|
||||
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) => {
|
||||
if (dirty) {
|
||||
document.title = `${ TITLE } (unsaved changes)`;
|
||||
@@ -251,37 +349,67 @@
|
||||
|
||||
.sidebar-left-strip {
|
||||
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);
|
||||
> * {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-left-tab, .tab-add {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-add-beat {
|
||||
width: 100%;
|
||||
padding: 8px 3px 8px 3px;
|
||||
}
|
||||
|
||||
.sidebar-add-beat:hover,
|
||||
.sidebar-left-tab:hover:not(.active) {
|
||||
&:hover:not(.active) {
|
||||
cursor: pointer;
|
||||
background-color: var(--color-ui-neutral-dark);
|
||||
transition: background-color 200ms;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.delete {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-name {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 900px) {
|
||||
&.sidebar-visible .sidebar {
|
||||
left: 0;
|
||||
@@ -310,5 +438,9 @@
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-dialog {
|
||||
z-index: 20;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import RightHand from "@/assets/svgs/RH.png";
|
||||
import LeftFoot from "@/assets/svgs/LF.png";
|
||||
import RightFoot from "@/assets/svgs/RF.png";
|
||||
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 = {
|
||||
arrowClockwise: ArrowClockwise,
|
||||
@@ -20,6 +22,8 @@ export const IconUrlMap = {
|
||||
lf: LeftFoot,
|
||||
rf: RightFoot,
|
||||
eraser: Eraser,
|
||||
upload: Upload,
|
||||
save: Floppy,
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof IconUrlMap;
|
||||
|
||||
55
src/utils.ts
55
src/utils.ts
@@ -1,3 +1,5 @@
|
||||
import { effectScope, type EffectScope } from "vue";
|
||||
|
||||
export function isPosInt(maybePosInt: number): boolean {
|
||||
return (maybePosInt | 0) === maybePosInt && maybePosInt > 0;
|
||||
}
|
||||
@@ -10,3 +12,56 @@ export function greatestCommonDivisor(a: number, b: number): number {
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user