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
/dist
.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 =
| "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);
}

View File

@@ -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;

View File

@@ -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);
}

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 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,

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" });
app.mount("#app");
interface Window {
drumslayer?: Record<string, any>,
}

View File

@@ -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"

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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;

View File

@@ -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();
}
}