update
This commit is contained in:
3
assets/svgs/eraser-fill.svg
Normal file
3
assets/svgs/eraser-fill.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eraser-fill" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.086 2.207a2 2 0 0 1 2.828 0l3.879 3.879a2 2 0 0 1 0 2.828l-5.5 5.5A2 2 0 0 1 7.879 15H5.12a2 2 0 0 1-1.414-.586l-2.5-2.5a2 2 0 0 1 0-2.828l6.879-6.879zm.66 11.34L3.453 8.254 1.914 9.793a1 1 0 0 0 0 1.414l2.5 2.5a1 1 0 0 0 .707.293H7.88a1 1 0 0 0 .707-.293l.16-.16z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 418 B |
@@ -10,6 +10,7 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<div id="dropdowns"></div>
|
||||||
<script type="module" src='/src/main.ts'></script>
|
<script type="module" src='/src/main.ts'></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
2026
package-lock.json
generated
2026
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,8 @@
|
|||||||
"@vue/tsconfig": "^0.1.3",
|
"@vue/tsconfig": "^0.1.3",
|
||||||
"pinia": "^2.0.32",
|
"pinia": "^2.0.32",
|
||||||
"sass": "^1.58.3",
|
"sass": "^1.58.3",
|
||||||
"vue": "^3.2.47"
|
"vue": "^3.2.47",
|
||||||
|
"zod": "^3.21.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.40",
|
"@types/node": "^17.0.40",
|
||||||
|
|||||||
269
src/Beat.ts
269
src/Beat.ts
@@ -1,6 +1,8 @@
|
|||||||
import { deserialise as deserialiseTrack, type TrackInitOptions, createTrack, isValidTimeSigRange, type Track } from "@/Track";
|
import { deserialise as deserialiseTrack, type TrackInitOptions, isValidTimeSigRange, Track } from "@/Track";
|
||||||
import { greatestCommonDivisor, isPosInt } from "@/utils";
|
import { greatestCommonDivisor, isPosInt } from "@/utils";
|
||||||
import { watch, computed, effectScope, ref, shallowRef, triggerRef } from "vue";
|
import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef } from "vue";
|
||||||
|
import EffectScoped from "./EffectScoped";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
export interface BeatManager {
|
export interface BeatManager {
|
||||||
notifyChange(): void;
|
notifyChange(): void;
|
||||||
@@ -17,58 +19,41 @@ type BeatGroupInitOptions = {
|
|||||||
name?: string,
|
name?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BeatSerial = {
|
const BeatSerialSchema = z.object({
|
||||||
tracks: Record<string, any>[],
|
tracks: z.array(z.any()),
|
||||||
barCount: number,
|
barCount: z.number(),
|
||||||
timeSigUp: number,
|
timeSigUp: z.number(),
|
||||||
globalLoopLength: number,
|
globalLoopLength: z.number(),
|
||||||
globalIsLooping: boolean,
|
globalIsLooping: z.boolean(),
|
||||||
useAutoBeatLength: boolean,
|
useAutoBeatLength: z.boolean(),
|
||||||
barSettingsLocked: boolean,
|
barSettingsLocked: z.boolean(),
|
||||||
name: string,
|
name: z.string(),
|
||||||
};
|
|
||||||
|
|
||||||
function isBeatSerial(serial: Record<string, unknown>): serial is BeatSerial {
|
|
||||||
return Array.isArray(serial.tracks) &&
|
|
||||||
typeof serial.barCount === "number" &&
|
|
||||||
typeof serial.timeSigUp === "number" &&
|
|
||||||
typeof serial.globalLoopLength === "number" &&
|
|
||||||
typeof serial.globalIsLooping === "boolean" &&
|
|
||||||
typeof serial.useAutoBeatLength === "boolean" &&
|
|
||||||
typeof serial.barSettingsLocked === "boolean";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserialise(serial: {}, manager: BeatManager) {
|
|
||||||
if (!isBeatSerial(serial)) {
|
|
||||||
throw new Error("Not a valid beat serial");
|
|
||||||
}
|
|
||||||
const newBeat = createBeat({
|
|
||||||
manager,
|
|
||||||
loopLength: serial.globalLoopLength,
|
|
||||||
barCount: serial.barCount,
|
|
||||||
isLooping: serial.globalIsLooping,
|
|
||||||
name: serial.name,
|
|
||||||
timeSigUp: serial.timeSigUp,
|
|
||||||
useAutoBeatLength: serial.useAutoBeatLength,
|
|
||||||
});
|
});
|
||||||
serial.tracks.forEach(trackSerial => {
|
export type BeatSerial = z.infer<typeof BeatSerialSchema>;
|
||||||
const track = deserialiseTrack(trackSerial, manager);
|
|
||||||
if (track) newBeat.tracks.value.push(track);
|
|
||||||
});
|
|
||||||
return newBeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBeat(opts: BeatGroupInitOptions) {
|
export class Beat extends EffectScoped {
|
||||||
const scope = effectScope();
|
private barCountInternal: Ref<number>;
|
||||||
return scope.run(() => {
|
private globalLoopLengthInternal: Ref<number>;
|
||||||
const manager = opts.manager;
|
manager: BeatManager;
|
||||||
const tracks = shallowRef<Track[]>([]);
|
tracks: ShallowRef<Track[]>;
|
||||||
const barCountInternal = ref<number>(opts.barCount ?? 4);
|
barCount: WritableComputedRef<number>;
|
||||||
const barCount = computed({
|
timeSigUp: Ref<number>;
|
||||||
get() {
|
globalLoopLength: WritableComputedRef<number>;
|
||||||
if (useAutoBeatLength.value) {
|
globalIsLooping: Ref<boolean>;
|
||||||
const loopLengths = [timeSigUp.value];
|
useAutoBeatLength: Ref<boolean>;
|
||||||
for (const track of tracks.value) {
|
barSettingsLocked: Ref<boolean>;
|
||||||
|
name: Ref<string>;
|
||||||
|
|
||||||
|
constructor(opts: BeatGroupInitOptions) {
|
||||||
|
super();
|
||||||
|
this.manager = opts.manager;
|
||||||
|
this.tracks = shallowRef<Track[]>([]);
|
||||||
|
this.barCountInternal = ref<number>(opts.barCount ?? 4);
|
||||||
|
this.barCount = computed({
|
||||||
|
get: () => {
|
||||||
|
if (this.useAutoBeatLength.value) {
|
||||||
|
const loopLengths = [this.timeSigUp.value];
|
||||||
|
for (const track of this.tracks.value) {
|
||||||
if (track.looping.value) {
|
if (track.looping.value) {
|
||||||
const loopLength = track.loopLength.value;
|
const loopLength = track.loopLength.value;
|
||||||
if (loopLengths.indexOf(loopLength) === -1) {
|
if (loopLengths.indexOf(loopLength) === -1) {
|
||||||
@@ -77,140 +62,136 @@ export function createBeat(opts: BeatGroupInitOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const smallestLoopLength = loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr), 1);
|
const smallestLoopLength = loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr), 1);
|
||||||
return smallestLoopLength / timeSigUp.value;
|
return smallestLoopLength / this.timeSigUp.value;
|
||||||
}
|
}
|
||||||
return barCountInternal.value;
|
return this.barCountInternal.value;
|
||||||
},
|
},
|
||||||
set(val) {
|
set: (val) => {
|
||||||
if (barSettingsLocked.value || !isPosInt(val)) {
|
if (this.barSettingsLocked.value || !isPosInt(val)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
barCountInternal.value = val;
|
this.barCountInternal.value = val;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const timeSigUp = ref<number>(opts.timeSigUp ?? 4);
|
this.timeSigUp = ref<number>(opts.timeSigUp ?? 4);
|
||||||
const globalLoopLengthInternal = ref<number>(opts.loopLength ?? timeSigUp.value);
|
this.globalLoopLengthInternal = ref<number>(opts.loopLength ?? this.timeSigUp.value);
|
||||||
const globalLoopLength = computed({
|
this.globalLoopLength = computed({
|
||||||
get() {
|
get: () => {
|
||||||
return globalLoopLengthInternal.value;
|
return this.globalLoopLengthInternal.value;
|
||||||
},
|
},
|
||||||
set(val) {
|
set: (val) => {
|
||||||
if (!isPosInt(val)) {
|
if (!isPosInt(val)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
globalLoopLengthInternal.value = val;
|
this.globalLoopLengthInternal.value = val;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const globalIsLooping = ref<boolean>(opts.isLooping ?? false);
|
this.globalIsLooping = ref<boolean>(opts.isLooping ?? false);
|
||||||
const useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
|
this.useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
|
||||||
const barSettingsLocked = computed(() => useAutoBeatLength.value);
|
this.barSettingsLocked = computed(() => this.useAutoBeatLength.value);
|
||||||
const name = ref(opts.name ?? 'Beat');
|
this.name = ref(opts.name ?? "Beat");
|
||||||
|
|
||||||
function setTimeSigUp(timeSigVal: number): void {
|
watch([this.barCount, this.timeSigUp], ([newBarCount, newTimeSigUp]) => {
|
||||||
if (!isValidTimeSigRange(timeSigVal)) {
|
for (const track of this.tracks.value) {
|
||||||
timeSigVal = timeSigUp.value;
|
track.barCount.value = newBarCount;
|
||||||
|
track.timeSigUp.value = newTimeSigUp;
|
||||||
}
|
}
|
||||||
timeSigUp.value = timeSigVal;
|
}, { immediate: true });
|
||||||
for (const track of tracks.value) {
|
}
|
||||||
|
|
||||||
|
setTimeSigUp(timeSigVal: number): void {
|
||||||
|
if (!isValidTimeSigRange(timeSigVal)) {
|
||||||
|
timeSigVal = this.timeSigUp.value;
|
||||||
|
}
|
||||||
|
this.timeSigUp.value = timeSigVal;
|
||||||
|
for (const track of this.tracks.value) {
|
||||||
track.timeSigUp.value = timeSigVal;
|
track.timeSigUp.value = timeSigVal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTrackByIndex(trackIndex: number) {
|
getTrackByIndex(trackIndex: number) {
|
||||||
const track = tracks.value[trackIndex];
|
const track = this.tracks.value[trackIndex];
|
||||||
if (!track) {
|
if (!track) {
|
||||||
throw new Error(`Could not find the track with index: ${trackIndex}`);
|
throw new Error(`Could not find the track with index: ${trackIndex}`);
|
||||||
}
|
}
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|
||||||
function swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
|
swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
|
||||||
const track1 = getTrackByIndex(trackIndex1);
|
const track1 = this.getTrackByIndex(trackIndex1);
|
||||||
const track2 = getTrackByIndex(trackIndex2);
|
const track2 = this.getTrackByIndex(trackIndex2);
|
||||||
tracks.value[trackIndex1] = track2;
|
this.tracks.value[trackIndex1] = track2;
|
||||||
tracks.value[trackIndex2] = track1;
|
this.tracks.value[trackIndex2] = track1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTrack(opts?: Omit<TrackInitOptions, 'manager'>): Track | null {
|
addTrack(options?: Omit<TrackInitOptions, 'manager'>): Track | null {
|
||||||
let newTrack: Track | null;
|
const optionsResolved = {
|
||||||
const options: TrackInitOptions = {
|
manager: this.manager,
|
||||||
manager,
|
bars: this.barCount.value,
|
||||||
bars: barCount.value,
|
isLooping: this.globalIsLooping.value,
|
||||||
isLooping: globalIsLooping.value,
|
loopLength: this.globalLoopLength.value,
|
||||||
loopLength: globalLoopLength.value,
|
...options,
|
||||||
...opts,
|
|
||||||
timeSig: {
|
timeSig: {
|
||||||
up: timeSigUp.value,
|
up: this.timeSigUp.value,
|
||||||
down: 4,
|
down: 4,
|
||||||
...opts?.timeSig ?? {},
|
...options?.timeSig ?? {},
|
||||||
},
|
},
|
||||||
};
|
} satisfies TrackInitOptions;
|
||||||
newTrack = createTrack(options) ?? null;
|
const newTrack = Track.asScoped(optionsResolved) ?? null;
|
||||||
if (newTrack) {
|
if (newTrack) {
|
||||||
tracks.value.push(newTrack);
|
this.tracks.value.push(newTrack);
|
||||||
triggerRef(tracks);
|
triggerRef(this.tracks);
|
||||||
}
|
}
|
||||||
return newTrack;
|
return newTrack;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTrack(index: number): void {
|
removeTrack(index: number): void {
|
||||||
const track = getTrackByIndex(index);
|
const track = this.getTrackByIndex(index);
|
||||||
tracks.value.splice(index, 1);
|
this.tracks.value.splice(index, 1);
|
||||||
track.destroy();
|
track.destroy();
|
||||||
triggerRef(tracks);
|
triggerRef(this.tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bakeLoops(): void {
|
bakeLoops(): void {
|
||||||
const barCountLooped = barCount.value;
|
const barCountLooped = this.barCount.value;
|
||||||
useAutoBeatLength.value = false;
|
this.useAutoBeatLength.value = false;
|
||||||
barCount.value = barCountLooped;
|
this.barCount.value = barCountLooped;
|
||||||
tracks.value.forEach(track => track.bakeLoops());
|
this.tracks.value.forEach(track => track.bakeLoops());
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialise(): Readonly<BeatSerial> {
|
serialise(): Readonly<BeatSerial> {
|
||||||
return {
|
return {
|
||||||
tracks: tracks.value.map(track => track.serialise()),
|
tracks: this.tracks.value.map(track => track.serialise()),
|
||||||
barCount: barCount.value,
|
barCount: this.barCount.value,
|
||||||
timeSigUp: timeSigUp.value,
|
timeSigUp: this.timeSigUp.value,
|
||||||
globalLoopLength: globalLoopLength.value,
|
globalLoopLength: this.globalLoopLength.value,
|
||||||
globalIsLooping: globalIsLooping.value,
|
globalIsLooping: this.globalIsLooping.value,
|
||||||
useAutoBeatLength: useAutoBeatLength.value,
|
useAutoBeatLength: this.useAutoBeatLength.value,
|
||||||
barSettingsLocked: barSettingsLocked.value,
|
barSettingsLocked: this.barSettingsLocked.value,
|
||||||
name: name.value,
|
name: this.name.value,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(tracks, () => {
|
export function deserialise(serial: unknown, manager: BeatManager): Beat | null {
|
||||||
manager.notifyChange();
|
const parse = BeatSerialSchema.safeParse(serial);
|
||||||
|
if (!parse.success) {
|
||||||
|
console.error('Encountered invalid beat serial:', parse.error, serial);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const beat = parse.data;
|
||||||
|
const newBeat = Beat.asScoped({
|
||||||
|
manager,
|
||||||
|
loopLength: beat.globalLoopLength,
|
||||||
|
barCount: beat.barCount,
|
||||||
|
isLooping: beat.globalIsLooping,
|
||||||
|
name: beat.name,
|
||||||
|
timeSigUp: beat.timeSigUp,
|
||||||
|
useAutoBeatLength: beat.useAutoBeatLength,
|
||||||
});
|
});
|
||||||
|
beat.tracks.forEach(trackSerial => {
|
||||||
watch([barCount, timeSigUp], ([newBarCount, newTimeSigUp]) => {
|
const track = deserialiseTrack(trackSerial, manager);
|
||||||
for (const track of tracks.value) {
|
if (track) newBeat.tracks.value.push(track);
|
||||||
track.barCount.value = newBarCount;
|
});
|
||||||
track.timeSigUp.value = newTimeSigUp;
|
return newBeat;
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
|
||||||
|
|
||||||
return {
|
|
||||||
tracks,
|
|
||||||
barCount,
|
|
||||||
timeSigUp,
|
|
||||||
globalLoopLength,
|
|
||||||
globalIsLooping,
|
|
||||||
useAutoBeatLength,
|
|
||||||
barSettingsLocked,
|
|
||||||
name,
|
|
||||||
|
|
||||||
setTimeSigUp,
|
|
||||||
getTrackByIndex,
|
|
||||||
swapTracksByIndices,
|
|
||||||
addTrack,
|
|
||||||
removeTrack,
|
|
||||||
bakeLoops,
|
|
||||||
serialise,
|
|
||||||
destroy: scope.stop,
|
|
||||||
};
|
|
||||||
})!;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Beat = ReturnType<typeof createBeat>;
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { type Beat, createBeat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat";
|
import { Beat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat";
|
||||||
import { inject, computed, type InjectionKey, ref, shallowRef, triggerRef, watch, onScopeDispose, nextTick } from "vue";
|
import { inject, computed, type InjectionKey, onScopeDispose, ref, shallowRef, triggerRef, watch, nextTick } from "vue";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const DrumSlayerSaveSchema = z.object({
|
||||||
|
orientation: z.union([z.literal("horizontal"), z.literal("vertical")]),
|
||||||
|
activeBeatIndex: z.number(),
|
||||||
|
beats: z.array(z.unknown()),
|
||||||
|
});
|
||||||
|
export type DrumSlayerSave = z.infer<typeof DrumSlayerSaveSchema>;
|
||||||
|
|
||||||
function defaultMainBeatGroup(manager: BeatManager): Beat {
|
function defaultMainBeatGroup(manager: BeatManager): Beat {
|
||||||
const defaultSettings = {
|
const defaultSettings = {
|
||||||
@@ -8,7 +16,7 @@ function defaultMainBeatGroup(manager: BeatManager): Beat {
|
|||||||
isLooping: false,
|
isLooping: false,
|
||||||
timeSigUp: 8,
|
timeSigUp: 8,
|
||||||
};
|
};
|
||||||
const mainBeatGroup = createBeat(defaultSettings);
|
const mainBeatGroup = Beat.asScoped(defaultSettings);
|
||||||
mainBeatGroup.addTrack({ name: "LF" });
|
mainBeatGroup.addTrack({ name: "LF" });
|
||||||
mainBeatGroup.addTrack({ name: "LH" });
|
mainBeatGroup.addTrack({ name: "LH" });
|
||||||
mainBeatGroup.addTrack({ name: "RH" });
|
mainBeatGroup.addTrack({ name: "RH" });
|
||||||
@@ -57,26 +65,25 @@ export function createBeatStore() {
|
|||||||
beats: serials,
|
beats: serials,
|
||||||
activeBeatIndex: activeBeatIndex.value,
|
activeBeatIndex: activeBeatIndex.value,
|
||||||
orientation: orientation.value ?? "horizontal",
|
orientation: orientation.value ?? "horizontal",
|
||||||
}));
|
} satisfies DrumSlayerSave));
|
||||||
saveDirty.value = false;
|
saveDirty.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadFromSave(source: any): void {
|
function loadFromSave(source: unknown): void {
|
||||||
beats.value.length = 0;
|
beats.value.length = 0;
|
||||||
if (Array.isArray(source.beats)
|
const parse = DrumSlayerSaveSchema.safeParse(source);
|
||||||
&& (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")
|
if (parse.success) {
|
||||||
&& typeof source.orientation === "string") {
|
parse.data.beats.forEach((beat: unknown) => {
|
||||||
try {
|
const deserialisedBeat = deserialiseBeat(beat, manager);
|
||||||
source.beats.forEach((beat: any) => beats.value.push(deserialiseBeat(beat, manager)));
|
if (deserialisedBeat) {
|
||||||
if (typeof source.activeBeatIndex === "number") {
|
beats.value.push(deserialisedBeat);
|
||||||
activeBeatIndex.value = source.activeBeatIndex;
|
|
||||||
}
|
}
|
||||||
orientation.value = source.orientation;
|
});
|
||||||
} catch (err) {
|
activeBeatIndex.value = parse.data.activeBeatIndex;
|
||||||
console.error(err);
|
orientation.value = parse.data.orientation;
|
||||||
}
|
}
|
||||||
} else {
|
if (beats.value.length === 0) {
|
||||||
resetActiveBeat();
|
resetActiveBeat();
|
||||||
}
|
}
|
||||||
nextTick(() => saveDirty.value = false);
|
nextTick(() => saveDirty.value = false);
|
||||||
|
|||||||
41
src/EffectScoped.ts
Normal file
41
src/EffectScoped.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
267
src/Track.ts
267
src/Track.ts
@@ -1,6 +1,8 @@
|
|||||||
import { isPosInt } from "@/utils";
|
import { isPosInt } from "@/utils";
|
||||||
import { ref, shallowRef, computed, watch, reactive, triggerRef, effectScope } 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";
|
||||||
|
|
||||||
export type TrackInitOptions = {
|
export type TrackInitOptions = {
|
||||||
manager: BeatManager,
|
manager: BeatManager,
|
||||||
@@ -16,62 +18,54 @@ export type TrackInitOptions = {
|
|||||||
loopLength?: number,
|
loopLength?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrackSerial = {
|
const TrackSerialSchema = z.object({
|
||||||
name: string,
|
name: z.string(),
|
||||||
timeSigUp: number,
|
timeSigUp: z.number(),
|
||||||
timeSigDown: number,
|
timeSigDown: z.number(),
|
||||||
units: {
|
units: z.object({
|
||||||
isOn: boolean[],
|
isOn: z.array(z.boolean()),
|
||||||
type: number[],
|
type: z.array(z.number()),
|
||||||
stickingType: number[],
|
stickingType: z.array(z.number()),
|
||||||
},
|
}),
|
||||||
barCount: number,
|
barCount: z.number(),
|
||||||
loopLength: number,
|
loopLength: z.number(),
|
||||||
looping: boolean,
|
looping: z.boolean(),
|
||||||
}
|
});
|
||||||
|
export type TrackSerial = z.infer<typeof TrackSerialSchema>;
|
||||||
|
|
||||||
export const TrackUnitTypeList = [ "Normal", "GhostNote", "Accent", "GhostNoteAccent" ] as const;
|
export const TrackUnitTypeList = [ "Normal", "GhostNote", "Accent", "GhostNoteAccent" ] as const;
|
||||||
export type TrackUnitType = typeof TrackUnitTypeList[number];
|
export type TrackUnitType = typeof TrackUnitTypeList[number];
|
||||||
|
|
||||||
export const TrackUnitStickingTypeList = [ "none", "lh", "rh", "lf", "rf" ] as const;
|
export const PaintableTrackUnitStickingTypeList = [ "lh", "rh", "lf", "rf" ] as const;
|
||||||
|
export type PaintableTrackUnitStickingType = typeof PaintableTrackUnitStickingTypeList[number];
|
||||||
|
|
||||||
|
export const TrackUnitStickingTypeList = [ "lh", "rh", "lf", "rf" ] as const;
|
||||||
export type TrackUnitStickingType = typeof TrackUnitStickingTypeList[number];
|
export type TrackUnitStickingType = typeof TrackUnitStickingTypeList[number];
|
||||||
|
|
||||||
export function isValidTimeSigRange(sig: number): boolean {
|
export function isValidTimeSigRange(sig: number): boolean {
|
||||||
return sig >= 2 && sig <= 32;
|
return sig >= 2 && sig <= 32;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTrackSerial(serial: any): serial is TrackSerial {
|
export function deserialise(serial: unknown, manager: BeatManager) {
|
||||||
const correctTypes = typeof serial.name === "string" &&
|
const parse = TrackSerialSchema.safeParse(serial);
|
||||||
typeof serial.timeSigUp === "number" &&
|
if (!parse.success) {
|
||||||
typeof serial.timeSigDown === "number" &&
|
|
||||||
typeof serial.units === "object" &&
|
|
||||||
Array.isArray(serial.units.isOn) &&
|
|
||||||
Array.isArray(serial.units.type) &&
|
|
||||||
Array.isArray(serial.units.stickingType) &&
|
|
||||||
typeof serial.barCount === "number" &&
|
|
||||||
typeof serial.loopLength === "number" &&
|
|
||||||
typeof serial.looping === "boolean";
|
|
||||||
return correctTypes && serial.units.isOn.length === serial.units.type.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserialise(serial: Record<string, unknown>, manager: BeatManager) {
|
|
||||||
if (!isTrackSerial(serial)) {
|
|
||||||
throw new Error("Invalid track serial.");
|
throw new Error("Invalid track serial.");
|
||||||
}
|
}
|
||||||
const units = serial.units.isOn.map((isOn, i) => ({
|
const beat = parse.data;
|
||||||
|
const units = beat.units.isOn.map((isOn, i) => ({
|
||||||
on: isOn,
|
on: isOn,
|
||||||
type: serial.units.type[i] ?? 0,
|
type: beat.units.type[i] ?? 0,
|
||||||
stickingType: serial.units.stickingType[i] ?? 0,
|
stickingType: beat.units.stickingType[i] ?? 0,
|
||||||
}));
|
}));
|
||||||
return createTrack({
|
return Track.asScoped({
|
||||||
manager,
|
manager,
|
||||||
bars: serial.barCount,
|
bars: beat.barCount,
|
||||||
isLooping: serial.looping,
|
isLooping: beat.looping,
|
||||||
loopLength: serial.loopLength,
|
loopLength: beat.loopLength,
|
||||||
name: serial.name,
|
name: beat.name,
|
||||||
timeSig: {
|
timeSig: {
|
||||||
up: serial.timeSigUp,
|
up: beat.timeSigUp,
|
||||||
down: serial.timeSigDown,
|
down: beat.timeSigDown,
|
||||||
},
|
},
|
||||||
units,
|
units,
|
||||||
});
|
});
|
||||||
@@ -83,99 +77,115 @@ export type TrackUnit = {
|
|||||||
stickingType: number,
|
stickingType: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createTrack(options: TrackInitOptions) {
|
export class Track extends EffectScoped {
|
||||||
const scope = effectScope();
|
private loopLengthInternal: any;
|
||||||
return scope.run(() => {
|
private timeSig: { up: number, down: number };
|
||||||
const name = ref(options.name ?? 'New Track');
|
private manager: BeatManager;
|
||||||
const timeSig = reactive({ up: options.timeSig?.up ?? 4, down: options.timeSig?.down ?? 4 });
|
name: Ref<string>;
|
||||||
const timeSigUp = computed({
|
timeSigUp: Ref<number>;
|
||||||
get() {
|
timeSigDown: Ref<number>;
|
||||||
return timeSig.up;
|
unitRecord: ShallowRef<TrackUnit[]>;
|
||||||
|
barCount: Ref<number>;
|
||||||
|
loopLength: Ref<number>;
|
||||||
|
looping: Ref<boolean>;
|
||||||
|
|
||||||
|
constructor(options: TrackInitOptions) {
|
||||||
|
super();
|
||||||
|
this.manager = options.manager;
|
||||||
|
this.name = ref(options.name ?? 'New Track');
|
||||||
|
this.timeSig = reactive({ up: options.timeSig?.up ?? 4, down: options.timeSig?.down ?? 4 });
|
||||||
|
this.timeSigUp = computed({
|
||||||
|
get: () => {
|
||||||
|
return this.timeSig.up;
|
||||||
},
|
},
|
||||||
set(val) {
|
set: (val) => {
|
||||||
if (isValidTimeSigRange(val)) {
|
if (isValidTimeSigRange(val)) {
|
||||||
timeSig.up = val | 0;
|
this.timeSig.up = val | 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const timeSigDown = computed({
|
this.timeSigDown = computed({
|
||||||
get() {
|
get: () => {
|
||||||
return timeSig.down;
|
return this.timeSig.down;
|
||||||
},
|
},
|
||||||
set(val) {
|
set: (val) => {
|
||||||
if (isValidTimeSigRange(val)) {
|
if (isValidTimeSigRange(val)) {
|
||||||
timeSig.down = val | 0;
|
this.timeSig.down = val | 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const unitRecord = shallowRef<TrackUnit[]>(options.units ?? []);
|
this.unitRecord = shallowRef(options.units ?? []);
|
||||||
const barCount = ref(options.barCount ?? 4);
|
this.barCount = ref(options.barCount ?? 4);
|
||||||
const loopLengthInternal = ref(options?.loopLength ?? timeSigUp.value * barCount.value);
|
this.loopLengthInternal = ref(options?.loopLength ?? this.timeSigUp.value * this.barCount.value);
|
||||||
const loopLength = computed({
|
this.loopLength = computed({
|
||||||
get() {
|
get: () => {
|
||||||
return loopLengthInternal.value;
|
return this.loopLengthInternal.value;
|
||||||
},
|
},
|
||||||
set(val) {
|
set: (val) => {
|
||||||
if (!isPosInt(val) || val < 2) {
|
if (!isPosInt(val) || val < 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loopLengthInternal.value = val;
|
this.loopLengthInternal.value = val;
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const looping = ref(options?.isLooping ?? false);
|
this.looping = ref(options?.isLooping ?? false);
|
||||||
|
|
||||||
function getUnitByIndex(index: number): TrackUnit | null {
|
watch(this.unitRecord, () => this.manager.notifyChange());
|
||||||
if (looping.value) {
|
watch([this.barCount, this.timeSigDown, this.timeSigUp], () => this.updateTrackUnitLength(), { immediate: true });
|
||||||
index %= loopLength.value;
|
|
||||||
}
|
|
||||||
return unitRecord.value[index] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTrackUnitLength() {
|
getUnitByIndex(index: number): TrackUnit | null {
|
||||||
const newBarCount = barCount.value * timeSigUp.value;
|
if (this.looping.value) {
|
||||||
if (newBarCount < unitRecord.value.length) {
|
index %= this.loopLength.value;
|
||||||
unitRecord.value.splice(barCount.value * timeSigUp.value, unitRecord.value.length - newBarCount);
|
}
|
||||||
} else if (newBarCount > unitRecord.value.length) {
|
return this.unitRecord.value[index] ?? null;
|
||||||
const barsToAdd = newBarCount - unitRecord.value.length;
|
}
|
||||||
|
|
||||||
|
private updateTrackUnitLength() {
|
||||||
|
const newBarCount = this.barCount.value * this.timeSigUp.value;
|
||||||
|
if (newBarCount < this.unitRecord.value.length) {
|
||||||
|
this.unitRecord.value.splice(this.barCount.value * this.timeSigUp.value, this.unitRecord.value.length - newBarCount);
|
||||||
|
} else if (newBarCount > this.unitRecord.value.length) {
|
||||||
|
const barsToAdd = newBarCount - this.unitRecord.value.length;
|
||||||
for (let i = 0; i < barsToAdd; i++) {
|
for (let i = 0; i < barsToAdd; i++) {
|
||||||
unitRecord.value.push(createTrackUnit());
|
this.unitRecord.value.push(this.createTrackUnit());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bakeLoops(): void {
|
bakeLoops(): void {
|
||||||
if (looping.value) {
|
if (this.looping.value) {
|
||||||
unitRecord.value.forEach((unit, i) => {
|
this.unitRecord.value.forEach((unit, i) => {
|
||||||
const reprUnitAtPos = getUnitByIndex(i);
|
const reprUnitAtPos = this.getUnitByIndex(i);
|
||||||
if (reprUnitAtPos) {
|
if (reprUnitAtPos) {
|
||||||
mimicUnit(unit, reprUnitAtPos);
|
this.mimicUnit(unit, reprUnitAtPos);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
looping.value = false;
|
this.looping.value = false;
|
||||||
}
|
}
|
||||||
barCount.value
|
this.barCount.value
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialise(): Readonly<TrackSerial> {
|
serialise(): Readonly<TrackSerial> {
|
||||||
return {
|
return {
|
||||||
name: name.value,
|
name: this.name.value,
|
||||||
timeSigUp: timeSigUp.value,
|
timeSigUp: this.timeSigUp.value,
|
||||||
timeSigDown: timeSigDown.value,
|
timeSigDown: this.timeSigDown.value,
|
||||||
units: {
|
units: {
|
||||||
isOn: unitRecord.value.map(unit => unit.on),
|
isOn: this.unitRecord.value.map(unit => unit.on),
|
||||||
type: unitRecord.value.map(unit => unit.type),
|
type: this.unitRecord.value.map(unit => unit.type),
|
||||||
stickingType: unitRecord.value.map(unit => unit.stickingType),
|
stickingType: this.unitRecord.value.map(unit => unit.stickingType),
|
||||||
},
|
},
|
||||||
barCount: barCount.value,
|
barCount: this.barCount.value,
|
||||||
loopLength: loopLength.value,
|
loopLength: this.loopLength.value,
|
||||||
looping: looping.value,
|
looping: this.looping.value,
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTrackUnit(): TrackUnit {
|
private createTrackUnit(): TrackUnit {
|
||||||
return {
|
return {
|
||||||
on: false,
|
on: false,
|
||||||
type: 0,
|
type: 0,
|
||||||
@@ -183,44 +193,44 @@ export function createTrack(options: TrackInitOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStickingType(index: number, stickingType: number): void {
|
setStickingType(index: number, stickingType: number): void {
|
||||||
const unit = getUnitByIndex(index);
|
const unit = this.getUnitByIndex(index);
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unit.stickingType = stickingType;
|
unit.stickingType = stickingType;
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUnitOn(index: number, on: boolean): void {
|
setUnitOn(index: number, on: boolean): void {
|
||||||
const unit = getUnitByIndex(index);
|
const unit = this.getUnitByIndex(index);
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unit.on = on;
|
unit.on = on;
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUnit(index: number, update: Partial<TrackUnit>) {
|
updateUnit(index: number, update: Partial<TrackUnit>) {
|
||||||
const unit = getUnitByIndex(index);
|
const unit = this.getUnitByIndex(index);
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Object.assign(unit, update);
|
Object.assign(unit, update);
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleUnit(index: number): void {
|
toggleUnit(index: number): void {
|
||||||
const unit = getUnitByIndex(index);
|
const unit = this.getUnitByIndex(index);
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
unit.on = !unit.on;
|
unit.on = !unit.on;
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rotateUnit(index: number): void {
|
rotateUnit(index: number): void {
|
||||||
const unit = getUnitByIndex(index);
|
const unit = this.getUnitByIndex(index);
|
||||||
if (!unit) {
|
if (!unit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -229,40 +239,13 @@ export function createTrack(options: TrackInitOptions) {
|
|||||||
} else {
|
} else {
|
||||||
unit.type += 1;
|
unit.type += 1;
|
||||||
}
|
}
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mimicUnit(unitA: TrackUnit, unitB: TrackUnit): void {
|
mimicUnit(unitA: TrackUnit, unitB: TrackUnit): void {
|
||||||
unitA.on = unitB.on;
|
unitA.on = unitB.on;
|
||||||
unitA.type = unitB.type;
|
unitA.type = unitB.type;
|
||||||
unitA.stickingType = unitB.stickingType;
|
unitA.stickingType = unitB.stickingType;
|
||||||
triggerRef(unitRecord);
|
triggerRef(this.unitRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
const manager = options.manager;
|
|
||||||
watch(unitRecord, () => manager.notifyChange());
|
|
||||||
watch([barCount, timeSigDown, timeSigUp], () => updateTrackUnitLength(), { immediate: true });
|
|
||||||
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
timeSigUp,
|
|
||||||
timeSigDown,
|
|
||||||
unitRecord,
|
|
||||||
barCount,
|
|
||||||
loopLength,
|
|
||||||
looping,
|
|
||||||
|
|
||||||
serialise,
|
|
||||||
bakeLoops,
|
|
||||||
rotateUnit,
|
|
||||||
toggleUnit,
|
|
||||||
setUnitOn,
|
|
||||||
updateUnit,
|
|
||||||
getUnitByIndex,
|
|
||||||
setStickingType,
|
|
||||||
destroy: scope.stop,
|
|
||||||
};
|
|
||||||
})!;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Track = ReturnType<typeof createTrack>;
|
|
||||||
|
|||||||
@@ -216,7 +216,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toolbox {
|
.toolbox {
|
||||||
flex: 1;
|
|
||||||
width: 2em;
|
width: 2em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,61 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="toolbox">
|
<div class="toolbox">
|
||||||
<div class="main-row">
|
<div class="main-row">
|
||||||
<icon
|
<dropdown>
|
||||||
class="toolbox-button"
|
<div class="toolbox-button paint-button-cont"
|
||||||
icon-name="lh"
|
|
||||||
:class="{ active: selectedTool === 'track-unit-type' }"
|
:class="{ active: selectedTool === 'track-unit-type' }"
|
||||||
@click="selectedTool = 'track-unit-type'" />
|
@click="selectedTool = 'track-unit-type'">
|
||||||
<icon
|
<div
|
||||||
class="toolbox-button"
|
class="paint-button"
|
||||||
icon-name="lf"
|
:class="getClasses({ on: true, type: TrackUnitTypeList[activeTrackUnitType]!, stickingType: null })" />
|
||||||
:class="{ active: selectedTool === 'sticking' }"
|
|
||||||
@click="selectedTool = 'sticking'" />
|
|
||||||
<icon
|
|
||||||
class="toolbox-button"
|
|
||||||
:class="{ active: selectedTool === 'eraser' }"
|
|
||||||
@click="selectedTool = 'eraser'" />
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedTool === 'track-unit-type'" class="details">
|
<template #content>
|
||||||
|
<div class="details">
|
||||||
<div v-for="(type, i) in TrackUnitTypeList"
|
<div v-for="(type, i) in TrackUnitTypeList"
|
||||||
:key="type"
|
:key="type"
|
||||||
class="toolbox-button"
|
class="toolbox-button"
|
||||||
:class="{ active: i === activeTrackUnitType }"
|
:class="{ active: i === activeTrackUnitType }"
|
||||||
@click="activeTrackUnitType = i">
|
@click="activeTrackUnitType = i; selectedTool = 'track-unit-type'">
|
||||||
<div :class="getClasses({ on: true, type, stickingType: 'none' })" />
|
<div :class="getClasses({ on: true, type, stickingType: null })" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedTool === 'sticking'" class="details">
|
</template>
|
||||||
<div v-for="(stickingType, i) in TrackUnitStickingTypeList.slice(1)"
|
</dropdown>
|
||||||
|
<dropdown>
|
||||||
|
<icon
|
||||||
|
class="toolbox-button"
|
||||||
|
:icon-name="PaintableTrackUnitStickingTypeList[activeStickingType]!"
|
||||||
|
:class="{ active: selectedTool === 'sticking' }"
|
||||||
|
@click="selectedTool = 'sticking'" />
|
||||||
|
<template #content>
|
||||||
|
<div class="details">
|
||||||
|
<div v-for="(stickingType, i) in PaintableTrackUnitStickingTypeList"
|
||||||
:key="stickingType"
|
:key="stickingType"
|
||||||
class="toolbox-button"
|
class="toolbox-button"
|
||||||
:class="{ active: i + 1 === activeStickingType }"
|
:class="{ active: i === activeStickingType }"
|
||||||
@click="activeStickingType = i + 1">
|
@click="activeStickingType = i; selectedTool = 'sticking'">
|
||||||
<icon :icon-name="StickingTypeIconMap[stickingType]" />
|
<icon :icon-name="StickingTypeIconMap[stickingType]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="selectedTool === 'eraser'" class="details hidden" />
|
</template>
|
||||||
|
</dropdown>
|
||||||
|
<dropdown>
|
||||||
|
<icon
|
||||||
|
class="toolbox-button"
|
||||||
|
icon-name="eraser"
|
||||||
|
:class="{ active: selectedTool === 'eraser' }"
|
||||||
|
@click="selectedTool = 'eraser'" />
|
||||||
|
</dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAppStateStore } from "@/AppState";
|
import { useAppStateStore } from "@/AppState";
|
||||||
import { TrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
|
import { PaintableTrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
|
||||||
import { StickingTypeIconMap } from "@/ui/TrackUnit/trackUnit";
|
import { StickingTypeIconMap } from "@/ui/TrackUnit/trackUnit";
|
||||||
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
import Icon from "@/ui/Widgets/Icon/Icon.vue";
|
||||||
|
import Dropdown from '@/ui/Widgets/Dropdown/Dropdown.vue';
|
||||||
import { getClasses } from "@/ui/TrackUnit/trackUnit";
|
import { getClasses } from "@/ui/TrackUnit/trackUnit";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -53,41 +66,60 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.details {
|
||||||
|
margin: auto;
|
||||||
|
padding: 0.5em;
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
}
|
||||||
|
|
||||||
.toolbox {
|
.toolbox {
|
||||||
.main-row {
|
.main-row {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
background-color: var(--color-ui-neutral-dark);
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details {
|
|
||||||
margin: auto;
|
|
||||||
height: 4em;
|
|
||||||
width: min-content;
|
|
||||||
border-radius: 0 0 1em 1em;
|
|
||||||
padding: 0.5em;
|
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&.hidden {
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-unit {
|
.track-unit {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-button {
|
.toolbox-button {
|
||||||
padding: 0.5em;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: black;
|
color: black;
|
||||||
|
|
||||||
|
&.paint-button-cont {
|
||||||
|
padding: 0.25em;
|
||||||
|
background-color: var(--color-ui-bg-dark);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--color-ui-bg-dark);
|
||||||
|
|
||||||
|
.paint-button {
|
||||||
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: var(--color-ui-bg-dark);
|
||||||
|
|
||||||
|
.paint-button {
|
||||||
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.paint-button {
|
||||||
|
height: 1.5em;
|
||||||
|
width: 1.5em;
|
||||||
|
background-color: black;
|
||||||
|
|
||||||
|
&.active {
|
||||||
background-color: var(--color-ui-neutral-dark);
|
background-color: var(--color-ui-neutral-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--color-ui-neutral-dark-hover);
|
background-color: var(--color-ui-neutral-dark-hover);
|
||||||
@@ -97,9 +129,11 @@
|
|||||||
background-color: var(--color-ui-neutral-dark-active);
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.details {
|
.details {
|
||||||
.toolbox-button {
|
.toolbox-button {
|
||||||
|
display: inline-block;
|
||||||
background-color: var(--color-ui-neutral-dark-active);
|
background-color: var(--color-ui-neutral-dark-active);
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -111,6 +145,5 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
@touchstart="handleTouchStart"
|
@touchstart="handleTouchStart"
|
||||||
@touchend="handleTouchEnd"
|
@touchend="handleTouchEnd"
|
||||||
@contextmenu.prevent.stop="() => false">
|
@contextmenu.prevent.stop="() => false">
|
||||||
<icon :icon-name="iconName" />
|
<icon v-if="iconName" :icon-name="iconName" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -35,7 +35,6 @@
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
selectingUnits,
|
selectingUnits,
|
||||||
lastTrackUnit,
|
|
||||||
deselectingUnits,
|
deselectingUnits,
|
||||||
unitMouseStart,
|
unitMouseStart,
|
||||||
} = useAppStateStore();
|
} = useAppStateStore();
|
||||||
@@ -47,12 +46,19 @@
|
|||||||
|
|
||||||
const classes = computed(() => getClasses({
|
const classes = computed(() => getClasses({
|
||||||
on: props.on,
|
on: props.on,
|
||||||
stickingType: TrackUnitStickingTypeList[props.stickingType] ?? 'none',
|
stickingType: TrackUnitStickingTypeList[props.stickingType] ?? null,
|
||||||
type: TrackUnitTypeList[props.type] ?? 'Normal',
|
type: TrackUnitTypeList[props.type] ?? 'Normal',
|
||||||
highlightable: true,
|
highlightable: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const iconName = computed(() => StickingTypeIconMap[TrackUnitStickingTypeList[props.stickingType] ?? 'none']);
|
const iconName = computed(() => {
|
||||||
|
const type = TrackUnitStickingTypeList[props.stickingType];
|
||||||
|
if (type) {
|
||||||
|
StickingTypeIconMap[type];
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function handleMouseDown(ev: MouseEvent): void {
|
function handleMouseDown(ev: MouseEvent): void {
|
||||||
blockNextMouseUp = false;
|
blockNextMouseUp = false;
|
||||||
@@ -118,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style scoped lang="scss">
|
||||||
.track-unit {
|
.track-unit {
|
||||||
width: 2em;
|
width: 2em;
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|||||||
@@ -4,26 +4,25 @@ import type { IconName } from "@/ui/Widgets/Icon/icons";
|
|||||||
export const TypeClasses = [ "Ghost", "Accent" ] as const;
|
export const TypeClasses = [ "Ghost", "Accent" ] as const;
|
||||||
|
|
||||||
export const StickingTypeIconMap = {
|
export const StickingTypeIconMap = {
|
||||||
none: null,
|
|
||||||
lf: 'lf',
|
lf: 'lf',
|
||||||
lh: 'lh',
|
lh: 'lh',
|
||||||
rf: 'rf',
|
rf: 'rf',
|
||||||
rh: 'rh',
|
rh: 'rh',
|
||||||
} as const satisfies Readonly<Record<TrackUnitStickingType, IconName | null>>;
|
} as const satisfies Record<TrackUnitStickingType, IconName | null>;
|
||||||
|
|
||||||
export const TrackUnitTypeClassMap = {
|
export const TrackUnitTypeClassMap = {
|
||||||
"Normal": [],
|
"Normal": [],
|
||||||
"GhostNote": ["Ghost"],
|
"GhostNote": ["Ghost"],
|
||||||
"Accent": ["Accent"],
|
"Accent": ["Accent"],
|
||||||
"GhostNoteAccent": ["Ghost", "Accent"],
|
"GhostNoteAccent": ["Ghost", "Accent"],
|
||||||
} as const satisfies Readonly<Record<TrackUnitType, Readonly<string[]>>>;
|
} as const satisfies Record<TrackUnitType, Readonly<string[]>>;
|
||||||
|
|
||||||
export function getClasses(options: { on: boolean, stickingType: TrackUnitStickingType, type: TrackUnitType, highlightable?: boolean }) {
|
export function getClasses(options: { on: boolean, stickingType: TrackUnitStickingType | null, type: TrackUnitType, highlightable?: boolean }) {
|
||||||
const classes = ["track-unit"];
|
const classes = ["track-unit"];
|
||||||
if (options.on) {
|
if (options.on) {
|
||||||
classes.push("on");
|
classes.push("on");
|
||||||
}
|
}
|
||||||
if (StickingTypeIconMap[options.stickingType]) {
|
if (options.stickingType && StickingTypeIconMap[options.stickingType]) {
|
||||||
classes.push("icon-visible");
|
classes.push("icon-visible");
|
||||||
}
|
}
|
||||||
if (options.type) {
|
if (options.type) {
|
||||||
|
|||||||
81
src/ui/Widgets/Dropdown/Dropdown.vue
Normal file
81
src/ui/Widgets/Dropdown/Dropdown.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="trigger"
|
||||||
|
ref="trigger"
|
||||||
|
@mouseenter="onMouseEnter"
|
||||||
|
@touchstart="onMouseEnter"
|
||||||
|
@mouseleave="onMouseLeave">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<Teleport to="#dropdowns">
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
ref="dropdown"
|
||||||
|
class="dropdown"
|
||||||
|
:class="{ visible }"
|
||||||
|
@onmouseleave="visible = false">
|
||||||
|
<slot name="content" />
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
const trigger = ref<HTMLDivElement>();
|
||||||
|
const dropdown = ref<HTMLDivElement>();
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const top = ref('0px');
|
||||||
|
const left = ref('0px');
|
||||||
|
|
||||||
|
function onMouseEnter(e: MouseEvent | TouchEvent) {
|
||||||
|
visible.value = true;
|
||||||
|
if (trigger.value) {
|
||||||
|
const rect = trigger.value.getBoundingClientRect();
|
||||||
|
top.value = rect.top + 'px';
|
||||||
|
left.value = rect.width + rect.left + 'px';
|
||||||
|
}
|
||||||
|
window.addEventListener('touchstart', onWindowClick);
|
||||||
|
window.addEventListener('click', onWindowClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outside(e: MouseEvent, el: HTMLElement) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return e.clientX < rect.x
|
||||||
|
|| e.clientX > rect.x + rect.width
|
||||||
|
|| e.clientY < rect.y
|
||||||
|
|| e.clientY > rect.y + rect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWindowClick(e: MouseEvent | TouchEvent) {
|
||||||
|
if (visible.value && e instanceof MouseEvent && dropdown.value && !outside(e, dropdown.value)) {
|
||||||
|
visible.value = false;
|
||||||
|
window.removeEventListener('touchstart', onWindowClick);
|
||||||
|
window.removeEventListener('click', onWindowClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseLeave(e: MouseEvent) {
|
||||||
|
if (trigger.value && outside(e, trigger.value)) {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: v-bind(top);
|
||||||
|
left: v-bind(left);
|
||||||
|
visibility: hidden;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,7 +26,6 @@
|
|||||||
height: 2em;
|
height: 2em;
|
||||||
-webkit-mask-size: 2em;
|
-webkit-mask-size: 2em;
|
||||||
mask-size: 2em;
|
mask-size: 2em;
|
||||||
display: inline-block;
|
|
||||||
background-color: var(--icon-bg);
|
background-color: var(--icon-bg);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import Download from "assets/svgs/download.svg";
|
|||||||
import RightHand from "assets/svgs/RH.png";
|
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";
|
||||||
|
|
||||||
export const IconUrlMap = {
|
export const IconUrlMap = {
|
||||||
arrowClockwise: ArrowClockwise,
|
arrowClockwise: ArrowClockwise,
|
||||||
@@ -18,6 +19,7 @@ export const IconUrlMap = {
|
|||||||
rh: RightHand,
|
rh: RightHand,
|
||||||
lf: LeftFoot,
|
lf: LeftFoot,
|
||||||
rf: RightFoot,
|
rf: RightFoot,
|
||||||
|
eraser: Eraser,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type IconName = keyof typeof IconUrlMap;
|
export type IconName = keyof typeof IconUrlMap;
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ html, body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#dropdowns {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#dropdowns * {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user