This commit is contained in:
Daniel Ledda
2023-05-07 21:14:48 +02:00
parent 90a39ee2fe
commit 4268cec832
17 changed files with 800 additions and 2292 deletions

View 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

View File

@@ -10,6 +10,7 @@
</head>
<body>
<div id="app"></div>
<div id="dropdowns"></div>
<script type="module" src='/src/main.ts'></script>
</body>
</html>

2026
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@
"@vue/tsconfig": "^0.1.3",
"pinia": "^2.0.32",
"sass": "^1.58.3",
"vue": "^3.2.47"
"vue": "^3.2.47",
"zod": "^3.21.4"
},
"devDependencies": {
"@types/node": "^17.0.40",

View File

@@ -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 { 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 {
notifyChange(): void;
@@ -17,58 +19,41 @@ type BeatGroupInitOptions = {
name?: string,
};
export type BeatSerial = {
tracks: Record<string, any>[],
barCount: number,
timeSigUp: number,
globalLoopLength: number,
globalIsLooping: boolean,
useAutoBeatLength: boolean,
barSettingsLocked: boolean,
name: string,
};
const BeatSerialSchema = z.object({
tracks: z.array(z.any()),
barCount: z.number(),
timeSigUp: z.number(),
globalLoopLength: z.number(),
globalIsLooping: z.boolean(),
useAutoBeatLength: z.boolean(),
barSettingsLocked: z.boolean(),
name: z.string(),
});
export type BeatSerial = z.infer<typeof BeatSerialSchema>;
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 class Beat extends EffectScoped {
private barCountInternal: Ref<number>;
private globalLoopLengthInternal: Ref<number>;
manager: BeatManager;
tracks: ShallowRef<Track[]>;
barCount: WritableComputedRef<number>;
timeSigUp: Ref<number>;
globalLoopLength: WritableComputedRef<number>;
globalIsLooping: Ref<boolean>;
useAutoBeatLength: Ref<boolean>;
barSettingsLocked: Ref<boolean>;
name: Ref<string>;
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 => {
const track = deserialiseTrack(trackSerial, manager);
if (track) newBeat.tracks.value.push(track);
});
return newBeat;
}
export function createBeat(opts: BeatGroupInitOptions) {
const scope = effectScope();
return scope.run(() => {
const manager = opts.manager;
const tracks = shallowRef<Track[]>([]);
const barCountInternal = ref<number>(opts.barCount ?? 4);
const barCount = computed({
get() {
if (useAutoBeatLength.value) {
const loopLengths = [timeSigUp.value];
for (const track of tracks.value) {
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) {
const loopLength = track.loopLength.value;
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);
return smallestLoopLength / timeSigUp.value;
return smallestLoopLength / this.timeSigUp.value;
}
return barCountInternal.value;
return this.barCountInternal.value;
},
set(val) {
if (barSettingsLocked.value || !isPosInt(val)) {
set: (val) => {
if (this.barSettingsLocked.value || !isPosInt(val)) {
return;
}
barCountInternal.value = val;
this.barCountInternal.value = val;
},
});
const timeSigUp = ref<number>(opts.timeSigUp ?? 4);
const globalLoopLengthInternal = ref<number>(opts.loopLength ?? timeSigUp.value);
const globalLoopLength = computed({
get() {
return globalLoopLengthInternal.value;
this.timeSigUp = ref<number>(opts.timeSigUp ?? 4);
this.globalLoopLengthInternal = ref<number>(opts.loopLength ?? this.timeSigUp.value);
this.globalLoopLength = computed({
get: () => {
return this.globalLoopLengthInternal.value;
},
set(val) {
set: (val) => {
if (!isPosInt(val)) {
return;
}
globalLoopLengthInternal.value = val;
this.globalLoopLengthInternal.value = val;
},
});
const globalIsLooping = ref<boolean>(opts.isLooping ?? false);
const useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
const barSettingsLocked = computed(() => useAutoBeatLength.value);
const name = ref(opts.name ?? 'Beat');
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");
function setTimeSigUp(timeSigVal: number): void {
if (!isValidTimeSigRange(timeSigVal)) {
timeSigVal = timeSigUp.value;
}
timeSigUp.value = timeSigVal;
for (const track of tracks.value) {
track.timeSigUp.value = timeSigVal;
}
}
function getTrackByIndex(trackIndex: number) {
const track = tracks.value[trackIndex];
if (!track) {
throw new Error(`Could not find the track with index: ${trackIndex}`);
}
return track;
}
function swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
const track1 = getTrackByIndex(trackIndex1);
const track2 = getTrackByIndex(trackIndex2);
tracks.value[trackIndex1] = track2;
tracks.value[trackIndex2] = track1;
}
function addTrack(opts?: Omit<TrackInitOptions, 'manager'>): Track | null {
let newTrack: Track | null;
const options: TrackInitOptions = {
manager,
bars: barCount.value,
isLooping: globalIsLooping.value,
loopLength: globalLoopLength.value,
...opts,
timeSig: {
up: timeSigUp.value,
down: 4,
...opts?.timeSig ?? {},
},
};
newTrack = createTrack(options) ?? null;
if (newTrack) {
tracks.value.push(newTrack);
triggerRef(tracks);
}
return newTrack;
}
function removeTrack(index: number): void {
const track = getTrackByIndex(index);
tracks.value.splice(index, 1);
track.destroy();
triggerRef(tracks);
}
function bakeLoops(): void {
const barCountLooped = barCount.value;
useAutoBeatLength.value = false;
barCount.value = barCountLooped;
tracks.value.forEach(track => track.bakeLoops());
}
function serialise(): Readonly<BeatSerial> {
return {
tracks: tracks.value.map(track => track.serialise()),
barCount: barCount.value,
timeSigUp: timeSigUp.value,
globalLoopLength: globalLoopLength.value,
globalIsLooping: globalIsLooping.value,
useAutoBeatLength: useAutoBeatLength.value,
barSettingsLocked: barSettingsLocked.value,
name: name.value,
} as const;
}
watch(tracks, () => {
manager.notifyChange();
});
watch([barCount, timeSigUp], ([newBarCount, newTimeSigUp]) => {
for (const track of tracks.value) {
watch([this.barCount, this.timeSigUp], ([newBarCount, newTimeSigUp]) => {
for (const track of this.tracks.value) {
track.barCount.value = newBarCount;
track.timeSigUp.value = newTimeSigUp;
}
}, { immediate: true });
}
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;
}
}
getTrackByIndex(trackIndex: number) {
const track = this.tracks.value[trackIndex];
if (!track) {
throw new Error(`Could not find the track with index: ${trackIndex}`);
}
return track;
}
swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
const track1 = this.getTrackByIndex(trackIndex1);
const track2 = this.getTrackByIndex(trackIndex2);
this.tracks.value[trackIndex1] = track2;
this.tracks.value[trackIndex2] = track1;
}
addTrack(options?: Omit<TrackInitOptions, 'manager'>): Track | null {
const optionsResolved = {
manager: this.manager,
bars: this.barCount.value,
isLooping: this.globalIsLooping.value,
loopLength: this.globalLoopLength.value,
...options,
timeSig: {
up: this.timeSigUp.value,
down: 4,
...options?.timeSig ?? {},
},
} satisfies TrackInitOptions;
const newTrack = Track.asScoped(optionsResolved) ?? null;
if (newTrack) {
this.tracks.value.push(newTrack);
triggerRef(this.tracks);
}
return newTrack;
}
removeTrack(index: number): void {
const track = this.getTrackByIndex(index);
this.tracks.value.splice(index, 1);
track.destroy();
triggerRef(this.tracks);
}
bakeLoops(): void {
const barCountLooped = this.barCount.value;
this.useAutoBeatLength.value = false;
this.barCount.value = barCountLooped;
this.tracks.value.forEach(track => track.bakeLoops());
}
serialise(): Readonly<BeatSerial> {
return {
tracks,
barCount,
timeSigUp,
globalLoopLength,
globalIsLooping,
useAutoBeatLength,
barSettingsLocked,
name,
setTimeSigUp,
getTrackByIndex,
swapTracksByIndices,
addTrack,
removeTrack,
bakeLoops,
serialise,
destroy: scope.stop,
};
})!;
tracks: this.tracks.value.map(track => track.serialise()),
barCount: this.barCount.value,
timeSigUp: this.timeSigUp.value,
globalLoopLength: this.globalLoopLength.value,
globalIsLooping: this.globalIsLooping.value,
useAutoBeatLength: this.useAutoBeatLength.value,
barSettingsLocked: this.barSettingsLocked.value,
name: this.name.value,
} as const;
}
}
export type Beat = ReturnType<typeof createBeat>;
export function deserialise(serial: unknown, manager: BeatManager): Beat | null {
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 => {
const track = deserialiseTrack(trackSerial, manager);
if (track) newBeat.tracks.value.push(track);
});
return newBeat;
}

View File

@@ -1,5 +1,13 @@
import { type Beat, createBeat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat";
import { inject, computed, type InjectionKey, ref, shallowRef, triggerRef, watch, onScopeDispose, nextTick } from "vue";
import { Beat, deserialise as deserialiseBeat, type BeatManager } from "@/Beat";
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 {
const defaultSettings = {
@@ -8,7 +16,7 @@ function defaultMainBeatGroup(manager: BeatManager): Beat {
isLooping: false,
timeSigUp: 8,
};
const mainBeatGroup = createBeat(defaultSettings);
const mainBeatGroup = Beat.asScoped(defaultSettings);
mainBeatGroup.addTrack({ name: "LF" });
mainBeatGroup.addTrack({ name: "LH" });
mainBeatGroup.addTrack({ name: "RH" });
@@ -57,26 +65,25 @@ export function createBeatStore() {
beats: serials,
activeBeatIndex: activeBeatIndex.value,
orientation: orientation.value ?? "horizontal",
}));
} satisfies DrumSlayerSave));
saveDirty.value = false;
}
}
function loadFromSave(source: any): void {
function loadFromSave(source: unknown): void {
beats.value.length = 0;
if (Array.isArray(source.beats)
&& (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")
&& typeof source.orientation === "string") {
try {
source.beats.forEach((beat: any) => beats.value.push(deserialiseBeat(beat, manager)));
if (typeof source.activeBeatIndex === "number") {
activeBeatIndex.value = source.activeBeatIndex;
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);
}
orientation.value = source.orientation;
} catch (err) {
console.error(err);
}
} else {
});
activeBeatIndex.value = parse.data.activeBeatIndex;
orientation.value = parse.data.orientation;
}
if (beats.value.length === 0) {
resetActiveBeat();
}
nextTick(() => saveDirty.value = false);

41
src/EffectScoped.ts Normal file
View 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();
}
}

View File

@@ -1,6 +1,8 @@
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 EffectScoped from "./EffectScoped";
import { z } from "zod";
export type TrackInitOptions = {
manager: BeatManager,
@@ -16,62 +18,54 @@ export type TrackInitOptions = {
loopLength?: number,
};
export type TrackSerial = {
name: string,
timeSigUp: number,
timeSigDown: number,
units: {
isOn: boolean[],
type: number[],
stickingType: number[],
},
barCount: number,
loopLength: number,
looping: boolean,
}
const TrackSerialSchema = z.object({
name: z.string(),
timeSigUp: z.number(),
timeSigDown: z.number(),
units: z.object({
isOn: z.array(z.boolean()),
type: z.array(z.number()),
stickingType: z.array(z.number()),
}),
barCount: z.number(),
loopLength: z.number(),
looping: z.boolean(),
});
export type TrackSerial = z.infer<typeof TrackSerialSchema>;
export const TrackUnitTypeList = [ "Normal", "GhostNote", "Accent", "GhostNoteAccent" ] as const;
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 function isValidTimeSigRange(sig: number): boolean {
return sig >= 2 && sig <= 32;
}
function isTrackSerial(serial: any): serial is TrackSerial {
const correctTypes = typeof serial.name === "string" &&
typeof serial.timeSigUp === "number" &&
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)) {
export function deserialise(serial: unknown, manager: BeatManager) {
const parse = TrackSerialSchema.safeParse(serial);
if (!parse.success) {
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,
type: serial.units.type[i] ?? 0,
stickingType: serial.units.stickingType[i] ?? 0,
type: beat.units.type[i] ?? 0,
stickingType: beat.units.stickingType[i] ?? 0,
}));
return createTrack({
return Track.asScoped({
manager,
bars: serial.barCount,
isLooping: serial.looping,
loopLength: serial.loopLength,
name: serial.name,
bars: beat.barCount,
isLooping: beat.looping,
loopLength: beat.loopLength,
name: beat.name,
timeSig: {
up: serial.timeSigUp,
down: serial.timeSigDown,
up: beat.timeSigUp,
down: beat.timeSigDown,
},
units,
});
@@ -83,186 +77,175 @@ export type TrackUnit = {
stickingType: number,
};
export function createTrack(options: TrackInitOptions) {
const scope = effectScope();
return scope.run(() => {
const name = ref(options.name ?? 'New Track');
const timeSig = reactive({ up: options.timeSig?.up ?? 4, down: options.timeSig?.down ?? 4 });
const timeSigUp = computed({
get() {
return timeSig.up;
export class Track extends EffectScoped {
private loopLengthInternal: any;
private timeSig: { up: number, down: number };
private manager: BeatManager;
name: Ref<string>;
timeSigUp: Ref<number>;
timeSigDown: Ref<number>;
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)) {
timeSig.up = val | 0;
this.timeSig.up = val | 0;
}
},
});
const timeSigDown = computed({
get() {
return timeSig.down;
this.timeSigDown = computed({
get: () => {
return this.timeSig.down;
},
set(val) {
set: (val) => {
if (isValidTimeSigRange(val)) {
timeSig.down = val | 0;
this.timeSig.down = val | 0;
}
},
});
const unitRecord = shallowRef<TrackUnit[]>(options.units ?? []);
const barCount = ref(options.barCount ?? 4);
const loopLengthInternal = ref(options?.loopLength ?? timeSigUp.value * barCount.value);
const loopLength = computed({
get() {
return loopLengthInternal.value;
this.unitRecord = shallowRef(options.units ?? []);
this.barCount = ref(options.barCount ?? 4);
this.loopLengthInternal = ref(options?.loopLength ?? this.timeSigUp.value * this.barCount.value);
this.loopLength = computed({
get: () => {
return this.loopLengthInternal.value;
},
set(val) {
set: (val) => {
if (!isPosInt(val) || val < 2) {
return;
}
loopLengthInternal.value = val;
triggerRef(unitRecord);
this.loopLengthInternal.value = val;
triggerRef(this.unitRecord);
},
});
const looping = ref(options?.isLooping ?? false);
this.looping = ref(options?.isLooping ?? false);
function getUnitByIndex(index: number): TrackUnit | null {
if (looping.value) {
index %= loopLength.value;
}
return unitRecord.value[index] ?? null;
watch(this.unitRecord, () => this.manager.notifyChange());
watch([this.barCount, this.timeSigDown, this.timeSigUp], () => this.updateTrackUnitLength(), { immediate: true });
}
getUnitByIndex(index: number): TrackUnit | null {
if (this.looping.value) {
index %= this.loopLength.value;
}
return this.unitRecord.value[index] ?? null;
}
function updateTrackUnitLength() {
const newBarCount = barCount.value * timeSigUp.value;
if (newBarCount < unitRecord.value.length) {
unitRecord.value.splice(barCount.value * timeSigUp.value, unitRecord.value.length - newBarCount);
} else if (newBarCount > unitRecord.value.length) {
const barsToAdd = newBarCount - unitRecord.value.length;
for (let i = 0; i < barsToAdd; i++) {
unitRecord.value.push(createTrackUnit());
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++) {
this.unitRecord.value.push(this.createTrackUnit());
}
}
triggerRef(this.unitRecord);
}
bakeLoops(): void {
if (this.looping.value) {
this.unitRecord.value.forEach((unit, i) => {
const reprUnitAtPos = this.getUnitByIndex(i);
if (reprUnitAtPos) {
this.mimicUnit(unit, reprUnitAtPos);
}
}
triggerRef(unitRecord);
});
this.looping.value = false;
}
this.barCount.value
triggerRef(this.unitRecord);
}
function bakeLoops(): void {
if (looping.value) {
unitRecord.value.forEach((unit, i) => {
const reprUnitAtPos = getUnitByIndex(i);
if (reprUnitAtPos) {
mimicUnit(unit, reprUnitAtPos);
}
});
looping.value = false;
}
barCount.value
triggerRef(unitRecord);
}
function serialise(): Readonly<TrackSerial> {
return {
name: name.value,
timeSigUp: timeSigUp.value,
timeSigDown: timeSigDown.value,
units: {
isOn: unitRecord.value.map(unit => unit.on),
type: unitRecord.value.map(unit => unit.type),
stickingType: unitRecord.value.map(unit => unit.stickingType),
},
barCount: barCount.value,
loopLength: loopLength.value,
looping: looping.value,
} as const;
}
function createTrackUnit(): TrackUnit {
return {
on: false,
type: 0,
stickingType: 0,
};
}
function setStickingType(index: number, stickingType: number): void {
const unit = getUnitByIndex(index);
if (!unit) {
return;
}
unit.stickingType = stickingType;
triggerRef(unitRecord);
}
function setUnitOn(index: number, on: boolean): void {
const unit = getUnitByIndex(index);
if (!unit) {
return;
}
unit.on = on;
triggerRef(unitRecord);
}
function updateUnit(index: number, update: Partial<TrackUnit>) {
const unit = getUnitByIndex(index);
if (!unit) {
return;
}
Object.assign(unit, update);
triggerRef(unitRecord);
}
function toggleUnit(index: number): void {
const unit = getUnitByIndex(index);
if (!unit) {
return;
}
unit.on = !unit.on;
triggerRef(unitRecord);
}
function rotateUnit(index: number): void {
const unit = getUnitByIndex(index);
if (!unit) {
return;
}
if (unit.type === TrackUnitTypeList.length - 1) {
unit.type = 0;
} else {
unit.type += 1;
}
triggerRef(unitRecord);
}
function mimicUnit(unitA: TrackUnit, unitB: TrackUnit): void {
unitA.on = unitB.on;
unitA.type = unitB.type;
unitA.stickingType = unitB.stickingType;
triggerRef(unitRecord);
}
const manager = options.manager;
watch(unitRecord, () => manager.notifyChange());
watch([barCount, timeSigDown, timeSigUp], () => updateTrackUnitLength(), { immediate: true });
serialise(): Readonly<TrackSerial> {
return {
name,
timeSigUp,
timeSigDown,
unitRecord,
barCount,
loopLength,
looping,
name: this.name.value,
timeSigUp: this.timeSigUp.value,
timeSigDown: this.timeSigDown.value,
units: {
isOn: this.unitRecord.value.map(unit => unit.on),
type: this.unitRecord.value.map(unit => unit.type),
stickingType: this.unitRecord.value.map(unit => unit.stickingType),
},
barCount: this.barCount.value,
loopLength: this.loopLength.value,
looping: this.looping.value,
} as const;
}
serialise,
bakeLoops,
rotateUnit,
toggleUnit,
setUnitOn,
updateUnit,
getUnitByIndex,
setStickingType,
destroy: scope.stop,
private createTrackUnit(): TrackUnit {
return {
on: false,
type: 0,
stickingType: 0,
};
})!;
}
}
export type Track = ReturnType<typeof createTrack>;
setStickingType(index: number, stickingType: number): void {
const unit = this.getUnitByIndex(index);
if (!unit) {
return;
}
unit.stickingType = stickingType;
triggerRef(this.unitRecord);
}
setUnitOn(index: number, on: boolean): void {
const unit = this.getUnitByIndex(index);
if (!unit) {
return;
}
unit.on = on;
triggerRef(this.unitRecord);
}
updateUnit(index: number, update: Partial<TrackUnit>) {
const unit = this.getUnitByIndex(index);
if (!unit) {
return;
}
Object.assign(unit, update);
triggerRef(this.unitRecord);
}
toggleUnit(index: number): void {
const unit = this.getUnitByIndex(index);
if (!unit) {
return;
}
unit.on = !unit.on;
triggerRef(this.unitRecord);
}
rotateUnit(index: number): void {
const unit = this.getUnitByIndex(index);
if (!unit) {
return;
}
if (unit.type === TrackUnitTypeList.length - 1) {
unit.type = 0;
} else {
unit.type += 1;
}
triggerRef(this.unitRecord);
}
mimicUnit(unitA: TrackUnit, unitB: TrackUnit): void {
unitA.on = unitB.on;
unitA.type = unitB.type;
unitA.stickingType = unitB.stickingType;
triggerRef(this.unitRecord);
}
}

View File

@@ -216,7 +216,6 @@
}
.toolbox {
flex: 1;
width: 2em;
}
}

View File

@@ -1,48 +1,61 @@
<template>
<div class="toolbox">
<div class="main-row">
<icon
class="toolbox-button"
icon-name="lh"
:class="{ active: selectedTool === 'track-unit-type' }"
@click="selectedTool = 'track-unit-type'" />
<icon
class="toolbox-button"
icon-name="lf"
:class="{ active: selectedTool === 'sticking' }"
@click="selectedTool = 'sticking'" />
<icon
class="toolbox-button"
:class="{ active: selectedTool === 'eraser' }"
@click="selectedTool = 'eraser'" />
<dropdown>
<div class="toolbox-button paint-button-cont"
:class="{ active: selectedTool === 'track-unit-type' }"
@click="selectedTool = 'track-unit-type'">
<div
class="paint-button"
:class="getClasses({ on: true, type: TrackUnitTypeList[activeTrackUnitType]!, stickingType: null })" />
</div>
<template #content>
<div class="details">
<div v-for="(type, i) in TrackUnitTypeList"
:key="type"
class="toolbox-button"
:class="{ active: i === activeTrackUnitType }"
@click="activeTrackUnitType = i; selectedTool = 'track-unit-type'">
<div :class="getClasses({ on: true, type, stickingType: null })" />
</div>
</div>
</template>
</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"
class="toolbox-button"
:class="{ active: i === activeStickingType }"
@click="activeStickingType = i; selectedTool = 'sticking'">
<icon :icon-name="StickingTypeIconMap[stickingType]" />
</div>
</div>
</template>
</dropdown>
<dropdown>
<icon
class="toolbox-button"
icon-name="eraser"
:class="{ active: selectedTool === 'eraser' }"
@click="selectedTool = 'eraser'" />
</dropdown>
</div>
<div v-if="selectedTool === 'track-unit-type'" class="details">
<div v-for="(type, i) in TrackUnitTypeList"
:key="type"
class="toolbox-button"
:class="{ active: i === activeTrackUnitType }"
@click="activeTrackUnitType = i">
<div :class="getClasses({ on: true, type, stickingType: 'none' })" />
</div>
</div>
<div v-else-if="selectedTool === 'sticking'" class="details">
<div v-for="(stickingType, i) in TrackUnitStickingTypeList.slice(1)"
:key="stickingType"
class="toolbox-button"
:class="{ active: i + 1 === activeStickingType }"
@click="activeStickingType = i + 1">
<icon :icon-name="StickingTypeIconMap[stickingType]" />
</div>
</div>
<div v-else-if="selectedTool === 'eraser'" class="details hidden" />
</div>
</template>
<script setup lang="ts">
import { useAppStateStore } from "@/AppState";
import { TrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
import { PaintableTrackUnitStickingTypeList, TrackUnitTypeList } from "@/Track";
import { StickingTypeIconMap } from "@/ui/TrackUnit/trackUnit";
import Icon from "@/ui/Widgets/Icon/Icon.vue";
import Dropdown from '@/ui/Widgets/Dropdown/Dropdown.vue';
import { getClasses } from "@/ui/TrackUnit/trackUnit";
const {
@@ -53,41 +66,60 @@
</script>
<style scoped lang="scss">
.details {
margin: auto;
padding: 0.5em;
background-color: var(--color-ui-neutral-dark-active);
}
.toolbox {
.main-row {
margin: auto;
display: flex;
background-color: var(--color-ui-neutral-dark);
justify-content: center;
width: 100%;
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 {
margin: 0;
padding: 0;
}
.toolbox-button {
padding: 0.5em;
cursor: pointer;
color: black;
background-color: var(--color-ui-neutral-dark);
&.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);
}
}
}
&:hover {
background-color: var(--color-ui-neutral-dark-hover);
@@ -97,18 +129,19 @@
background-color: var(--color-ui-neutral-dark-active);
}
}
}
.details {
.toolbox-button {
background-color: var(--color-ui-neutral-dark-active);
.details {
.toolbox-button {
display: inline-block;
background-color: var(--color-ui-neutral-dark-active);
&.active {
background-color: var(--color-ui-neutral-dark);
}
&.active {
background-color: var(--color-ui-neutral-dark);
}
&:hover {
background-color: var(--color-ui-neutral-dark-hover);
}
&:hover {
background-color: var(--color-ui-neutral-dark-hover);
}
}
}

View File

@@ -8,7 +8,7 @@
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@contextmenu.prevent.stop="() => false">
<icon :icon-name="iconName" />
<icon v-if="iconName" :icon-name="iconName" />
</div>
</template>
@@ -35,7 +35,6 @@
const {
selectingUnits,
lastTrackUnit,
deselectingUnits,
unitMouseStart,
} = useAppStateStore();
@@ -47,12 +46,19 @@
const classes = computed(() => getClasses({
on: props.on,
stickingType: TrackUnitStickingTypeList[props.stickingType] ?? 'none',
stickingType: TrackUnitStickingTypeList[props.stickingType] ?? null,
type: TrackUnitTypeList[props.type] ?? 'Normal',
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 {
blockNextMouseUp = false;
@@ -118,7 +124,7 @@
}
</script>
<style lang="scss">
<style scoped lang="scss">
.track-unit {
width: 2em;
height: 2em;

View File

@@ -4,26 +4,25 @@ import type { IconName } from "@/ui/Widgets/Icon/icons";
export const TypeClasses = [ "Ghost", "Accent" ] as const;
export const StickingTypeIconMap = {
none: null,
lf: 'lf',
lh: 'lh',
rf: 'rf',
rh: 'rh',
} as const satisfies Readonly<Record<TrackUnitStickingType, IconName | null>>;
} as const satisfies Record<TrackUnitStickingType, IconName | null>;
export const TrackUnitTypeClassMap = {
"Normal": [],
"GhostNote": ["Ghost"],
"Accent": ["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"];
if (options.on) {
classes.push("on");
}
if (StickingTypeIconMap[options.stickingType]) {
if (options.stickingType && StickingTypeIconMap[options.stickingType]) {
classes.push("icon-visible");
}
if (options.type) {

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

View File

@@ -26,7 +26,6 @@
height: 2em;
-webkit-mask-size: 2em;
mask-size: 2em;
display: inline-block;
background-color: var(--icon-bg);
}
</style>

View File

@@ -7,6 +7,7 @@ import Download from "assets/svgs/download.svg";
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";
export const IconUrlMap = {
arrowClockwise: ArrowClockwise,
@@ -18,6 +19,7 @@ export const IconUrlMap = {
rh: RightHand,
lf: LeftFoot,
rf: RightFoot,
eraser: Eraser,
} as const;
export type IconName = keyof typeof IconUrlMap;

View File

@@ -27,6 +27,20 @@ html, body {
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;
}