update
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@
|
|||||||
/public/static/*.js.map
|
/public/static/*.js.map
|
||||||
/dist
|
/dist
|
||||||
.idea
|
.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 =
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/Beat.ts
32
src/Beat.ts
@@ -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;
|
||||||
|
|||||||
172
src/BeatStore.ts
172
src/BeatStore.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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,
|
||||||
|
|||||||
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" });
|
const app = createApp(Root, { title: "Drum Slayer" });
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
drumslayer?: Record<string, any>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
57
src/utils.ts
57
src/utils.ts
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user