refactor: loads of sensible renaming

This commit is contained in:
Daniel Ledda
2022-04-03 14:29:56 +02:00
parent 1bfcd91132
commit 5b8e160608
31 changed files with 1364 additions and 1302 deletions

View File

@@ -1,169 +1,305 @@
import BeatUnit from "@/BeatUnit";
import Track, {TrackEvents, TrackInitOptions} from "@/Track";
import {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber";
import BeatLike from "@/BeatLike";
import {isPosInt} from "@/utils";
import {greatestCommonDivisor, isPosInt} from "@/utils";
export type BeatInitOptions = {
timeSig?: {
up: number,
down: number,
},
name?: string,
bars?: number,
isLooping?: boolean,
type BeatGroupInitOptions = {
barCount: number;
isLooping: boolean;
timeSigUp: number;
tracks?: TrackInitOptions[],
loopLength?: number,
useAutoBeatLength?: boolean,
name?: string,
};
export const enum BeatEvents {
NewTimeSig="be-0",
NewBarCount="be-1",
NewName="be-2",
DisplayTypeChanged="be-3",
LoopLengthChanged="be-4",
WantsRemoval="be-5",
Baked="be-6",
TrackOrderChanged="be-0",
TrackListChanged="be-1",
BarCountChanged="be-2",
TimeSigUpChanged="be-3",
AutoBeatSettingsChanged="be-4",
LockingChanged="be-5",
GlobalLoopLengthChanged="be-5",
GlobalDisplayTypeChanged="be-6",
NameChanged="be-7",
}
export default class Beat implements IPublisher<BeatEvents>, BeatLike {
private static count = 0;
private readonly key: string;
type EventTypeSubscriptions =
| TrackEvents.LoopLengthChanged
| TrackEvents.DisplayTypeChanged
| TrackEvents.WantsRemoval
| TrackEvents.Baked;
export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTypeSubscriptions> {
private static globalCounter = 0;
private tracks: Track[] = [];
private publisher: Publisher<BeatEvents, Beat> = new Publisher<BeatEvents, Beat>(this);
private barCount: number;
private timeSigUp: number;
private globalLoopLength: number;
private globalIsLooping: boolean;
private useAutoBeatLength: boolean;
private barSettingsLocked = false;
private name: string;
private timeSigUp = 4;
private timeSigDown = 4;
private readonly unitRecord: BeatUnit[] = [];
private barCount = 1;
private publisher = new Publisher<BeatEvents, Beat>(this);
private loopLength: number;
private looping: boolean;
constructor(options?: BeatInitOptions) {
this.key = `B-${Beat.count}`;
this.name = options?.name ?? this.key;
this.setTimeSignature({up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4});
this.setBarCount(options?.bars ?? 4);
Beat.count++;
this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount;
this.looping = options?.isLooping ?? false;
constructor(options?: BeatGroupInitOptions) {
Beat.globalCounter++;
if (options?.name) {
this.name = options.name;
} else {
this.name = `Pattern ${Beat.globalCounter}`;
}
if (options?.tracks) {
for (const trackOptions of options.tracks) {
this.addTrack(trackOptions);
}
}
this.barCount = options?.barCount ?? 4;
this.timeSigUp = options?.timeSigUp ?? 4;
this.globalLoopLength = options?.loopLength ?? this.timeSigUp;
this.globalIsLooping = options?.isLooping ?? false;
this.useAutoBeatLength = options?.useAutoBeatLength ?? false;
}
setLoopLength(loopLength: number): void {
if (!isPosInt(loopLength) || loopLength < 2) {
loopLength = this.loopLength;
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case TrackEvents.LoopLengthChanged:
case TrackEvents.DisplayTypeChanged:
this.autoBeatLength();
break;
case TrackEvents.WantsRemoval:
this.removeTrack((publisher as Track).getKey());
break;
case TrackEvents.Baked:
this.setIsUsingAutoBeatLength(false);
break;
}
this.loopLength = loopLength;
this.publisher.notifySubs(BeatEvents.LoopLengthChanged);
}
setLooping(isLooping: boolean): void {
this.looping = isLooping;
this.publisher.notifySubs(BeatEvents.DisplayTypeChanged);
}
addSubscriber(subscriber: ISubscriber<BeatEvents>, eventType: BeatEvents | BeatEvents[]): { unbind: () => void } {
return this.publisher.addSubscriber(subscriber, eventType);
}
setTimeSignature(timeSig: {up?: number, down?: number}): void {
if (timeSig.up && Beat.isValidTimeSigRange(timeSig.up)) {
this.timeSigUp = timeSig.up | 0;
}
if (timeSig.down && Beat.isValidTimeSigRange(timeSig.down)) {
this.timeSigDown = timeSig.down | 0;
}
this.updateBeatUnitLength();
this.publisher.notifySubs(BeatEvents.NewTimeSig);
}
setTimeSigUp(timeSigUp: number): void {
this.setTimeSignature({up: timeSigUp});
}
setTimeSigDown(timeSigUp: number): void {
this.setTimeSignature({down: timeSigUp});
}
setBarCount(barCount: number): void {
if (!isPosInt(barCount) || barCount == this.barCount) {
private setBarCountInternal(barCount: number): void {
if (!isPosInt(barCount)) {
barCount = this.barCount;
}
this.barCount = barCount;
this.updateBeatUnitLength();
this.publisher.notifySubs(BeatEvents.NewBarCount);
for (const track of this.tracks) {
track.setBarCount(barCount);
}
this.publisher.notifySubs(BeatEvents.BarCountChanged);
}
getUnitByIndex(index: number): BeatUnit | null {
if (this.looping) {
index %= this.loopLength;
setBarCount(barCount: number): void {
if (!this.barSettingsLocked) {
this.setBarCountInternal(barCount);
} else {
this.setBarCountInternal(this.barCount);
}
return this.unitRecord[index] ?? null;
}
private updateBeatUnitLength() {
const newBarCount = this.barCount * this.timeSigUp;
if (newBarCount < this.unitRecord.length) {
this.unitRecord.splice(this.barCount * this.timeSigUp, this.unitRecord.length - newBarCount);
} else if (newBarCount > this.unitRecord.length) {
const barsToAdd = newBarCount - this.unitRecord.length;
for (let i = 0; i < barsToAdd; i++) {
this.unitRecord.push(new BeatUnit());
}
}
}
getTimeSigUp(): number {
return this.timeSigUp;
}
getTimeSigDown(): number {
return this.timeSigDown;
}
getBarCount(): number {
return this.barCount;
}
getKey(): string {
return this.key;
setLoopLength(loopLength: number): void {
if (!isPosInt(loopLength)) {
return;
}
this.globalLoopLength = loopLength;
for (const track of this.tracks) {
track.setLoopLength(loopLength);
}
this.publisher.notifySubs(BeatEvents.GlobalLoopLengthChanged);
}
static isValidTimeSigRange(sig: number): boolean {
return sig >= 2 && sig <= 32;
getLoopLength(): number {
return this.globalLoopLength;
}
setLooping(isLooping: boolean): void {
this.globalIsLooping = isLooping;
for (const track of this.tracks) {
track.setLooping(isLooping);
}
this.publisher.notifySubs(BeatEvents.GlobalDisplayTypeChanged);
}
isLooping(): boolean {
return this.globalIsLooping;
}
private findSmallestLoopLength(): number {
const loopLengths = [this.timeSigUp];
for (const track of this.tracks) {
if (track.isLooping()) {
const loopLength = track.getLoopLength();
if (loopLengths.indexOf(loopLength) === -1) {
loopLengths.push(loopLength);
}
}
}
if (loopLengths.length === 1) {
loopLengths.push(1);
}
return loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr));
}
setTimeSigUp(timeSigUp: number): void {
if (!Track.isValidTimeSigRange(timeSigUp)) {
timeSigUp = this.timeSigUp;
}
this.timeSigUp = timeSigUp;
for (const track of this.tracks) {
track.setTimeSignature({up: timeSigUp});
}
this.autoBeatLength();
this.publisher.notifySubs(BeatEvents.TimeSigUpChanged);
}
getTimeSigUp(): number {
return this.timeSigUp;
}
getTrackByKey(trackKey: string): Track {
const foundTrack = this.tracks.find(track => track.getKey() === trackKey);
if (typeof foundTrack === "undefined") {
throw new Error(`Could not find the track with key: ${trackKey}`);
}
return foundTrack;
}
getTrackByIndex(trackIndex: number): Track {
if (!this.tracks[trackIndex]) {
throw new Error(`Could not find the track with index: ${trackIndex}`);
}
return this.tracks[trackIndex];
}
getTrackCount(): number {
return this.tracks.length;
}
getTrackKeys(): string[] {
return this.tracks.map(track => track.getKey());
}
swapTracksByIndices(trackIndex1: number, trackIndex2: number): void {
const track1 = this.getTrackByIndex(trackIndex1);
const track2 = this.getTrackByIndex(trackIndex2);
this.tracks[trackIndex1] = track2;
this.tracks[trackIndex2] = track1;
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
}
moveTrackBack(trackKey: string): void {
const index = this.tracks.indexOf(this.getTrackByKey(trackKey));
if (typeof index !== "undefined" && index > 0) {
this.swapTracksByIndices(index, index - 1);
}
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
this.publisher.notifySubs(BeatEvents.TrackListChanged);
}
moveTrackForward(trackKey: string): void {
const index = this.tracks.indexOf(this.getTrackByKey(trackKey));
if (typeof index !== "undefined" && index < this.getTrackCount()) {
this.swapTracksByIndices(index, index + 1);
}
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
this.publisher.notifySubs(BeatEvents.TrackListChanged);
}
canMoveTrackBack(trackKey: string): boolean {
return this.tracks.indexOf(this.getTrackByKey(trackKey)) > 0;
}
canMoveTrackForward(trackKey: string): boolean {
return this.tracks.indexOf(this.getTrackByKey(trackKey)) < this.tracks.length - 1;
}
addTrack(options?: TrackInitOptions): Track {
options = {
timeSig: {
up: this.timeSigUp,
down: 4,
},
bars: this.barCount,
isLooping: this.globalIsLooping,
loopLength: this.globalLoopLength,
...options
};
const newTrack = new Track(options);
this.tracks.push(newTrack);
newTrack.addSubscriber(this, [
TrackEvents.LoopLengthChanged,
TrackEvents.WantsRemoval,
TrackEvents.DisplayTypeChanged,
TrackEvents.Baked,
]);
this.publisher.notifySubs(BeatEvents.TrackListChanged);
return newTrack;
}
removeTrack(trackKey: string): void {
const track = this.getTrackByKey(trackKey);
this.tracks.splice(this.tracks.indexOf(track), 1);
this.autoBeatLength();
this.publisher.notifySubs(BeatEvents.TrackListChanged);
}
setTrackName(trackKey: string, newName: string): void {
this.getTrackByKey(trackKey).setName(newName);
this.publisher.notifySubs(BeatEvents.TrackOrderChanged);
}
autoBeatLengthOn(): boolean {
return this.useAutoBeatLength;
}
private autoBeatLength(): void {
if (this.useAutoBeatLength) {
this.setBarCountInternal(this.findSmallestLoopLength() / this.timeSigUp);
}
}
setIsUsingAutoBeatLength(isOn: boolean): void {
this.useAutoBeatLength = isOn;
this.autoBeatLength();
if (isOn) {
this.lockBars();
} else {
this.unlockBars();
}
this.publisher.notifySubs(BeatEvents.AutoBeatSettingsChanged);
}
barsLocked(): boolean {
return this.barSettingsLocked;
}
lockBars(): void {
this.barSettingsLocked = true;
this.publisher.notifySubs(BeatEvents.LockingChanged);
}
unlockBars(): void {
this.barSettingsLocked = false;
this.publisher.notifySubs(BeatEvents.LockingChanged);
}
bakeLoops(): void {
this.tracks.forEach(track => track.bakeLoops());
}
setName(newName: string): void {
this.name = newName;
this.publisher.notifySubs(BeatEvents.NewName);
this.publisher.notifySubs(BeatEvents.NameChanged);
}
getName(): string {
return this.name;
}
isLooping(): boolean {
return this.looping;
}
getLoopLength(): number {
return this.loopLength;
}
delete(): void {
this.publisher.notifySubs(BeatEvents.WantsRemoval);
}
bakeLoops(): void {
if (this.isLooping()) {
this.unitRecord.forEach((unit, i) => {
const reprUnitAtPos = this.getUnitByIndex(i);
if (reprUnitAtPos) {
unit.mimic(reprUnitAtPos);
}
});
this.publisher.notifySubs(BeatEvents.Baked);
this.setLooping(false);
} else {
this.publisher.notifySubs(BeatEvents.Baked);
}
}
}

View File

@@ -1,306 +0,0 @@
import Beat, {BeatEvents, BeatInitOptions} from "@/Beat";
import {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber";
import {greatestCommonDivisor, isPosInt} from "@/utils";
type BeatGroupInitOptions = {
barCount: number;
isLooping: boolean;
timeSigUp: number;
beats?: BeatInitOptions[],
loopLength?: number,
useAutoBeatLength?: boolean,
name?: string,
};
export const enum BeatGroupEvents {
BeatOrderChanged="bge-0",
BeatListChanged="bge-1",
BarCountChanged="bge-2",
TimeSigUpChanged="bge-3",
AutoBeatSettingsChanged="bge-4",
LockingChanged="bge-5",
GlobalLoopLengthChanged="bge-5",
GlobalDisplayTypeChanged="bge-6",
NameChanged="bge-7",
}
type EventTypeSubscriptions =
| BeatEvents.LoopLengthChanged
| BeatEvents.DisplayTypeChanged
| BeatEvents.WantsRemoval
| BeatEvents.Baked;
export default class BeatGroup implements IPublisher<BeatGroupEvents>, ISubscriber<EventTypeSubscriptions> {
private static globalCounter = 0;
private beats: Beat[] = [];
private publisher: Publisher<BeatGroupEvents, BeatGroup> = new Publisher<BeatGroupEvents, BeatGroup>(this);
private barCount: number;
private timeSigUp: number;
private globalLoopLength: number;
private globalIsLooping: boolean;
private useAutoBeatLength: boolean;
private barSettingsLocked = false;
private name: string;
constructor(options?: BeatGroupInitOptions) {
BeatGroup.globalCounter++;
if (options?.name) {
this.name = options.name;
} else {
this.name = `Pattern ${BeatGroup.globalCounter}`;
}
if (options?.beats) {
for (const beatOptions of options.beats) {
this.addBeat(beatOptions);
}
}
this.barCount = options?.barCount ?? 4;
this.timeSigUp = options?.timeSigUp ?? 4;
this.globalLoopLength = options?.loopLength ?? this.timeSigUp;
this.globalIsLooping = options?.isLooping ?? false;
this.useAutoBeatLength = options?.useAutoBeatLength ?? false;
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case BeatEvents.LoopLengthChanged:
case BeatEvents.DisplayTypeChanged:
this.autoBeatLength();
break;
case BeatEvents.WantsRemoval:
this.removeBeat((publisher as Beat).getKey());
break;
case BeatEvents.Baked:
this.setIsUsingAutoBeatLength(false);
break;
}
}
addSubscriber(subscriber: ISubscriber<BeatGroupEvents>, eventType: BeatGroupEvents | BeatGroupEvents[]): { unbind: () => void } {
return this.publisher.addSubscriber(subscriber, eventType);
}
private setBarCountInternal(barCount: number): void {
if (!isPosInt(barCount)) {
barCount = this.barCount;
}
this.barCount = barCount;
for (const beat of this.beats) {
beat.setBarCount(barCount);
}
this.publisher.notifySubs(BeatGroupEvents.BarCountChanged);
}
setBarCount(barCount: number): void {
if (!this.barSettingsLocked) {
this.setBarCountInternal(barCount);
} else {
this.setBarCountInternal(this.barCount);
}
}
getBarCount(): number {
return this.barCount;
}
setLoopLength(loopLength: number): void {
if (!isPosInt(loopLength)) {
return;
}
this.globalLoopLength = loopLength;
for (const beat of this.beats) {
beat.setLoopLength(loopLength);
}
this.publisher.notifySubs(BeatGroupEvents.GlobalLoopLengthChanged);
}
getLoopLength(): number {
return this.globalLoopLength;
}
setLooping(isLooping: boolean): void {
this.globalIsLooping = isLooping;
for (const beat of this.beats) {
beat.setLooping(isLooping);
}
this.publisher.notifySubs(BeatGroupEvents.GlobalDisplayTypeChanged);
}
isLooping(): boolean {
return this.globalIsLooping;
}
private findSmallestLoopLength(): number {
const loopLengths = [this.timeSigUp];
for (const beat of this.beats) {
if (beat.isLooping()) {
const loopLength = beat.getLoopLength();
if (loopLengths.indexOf(loopLength) === -1) {
loopLengths.push(loopLength);
}
}
}
if (loopLengths.length === 1) {
loopLengths.push(1);
}
return loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr));
}
setTimeSigUp(timeSigUp: number): void {
if (!Beat.isValidTimeSigRange(timeSigUp)) {
timeSigUp = this.timeSigUp;
}
this.timeSigUp = timeSigUp;
for (const beat of this.beats) {
beat.setTimeSignature({up: timeSigUp});
}
this.autoBeatLength();
this.publisher.notifySubs(BeatGroupEvents.TimeSigUpChanged);
}
getTimeSigUp(): number {
return this.timeSigUp;
}
getBeatByKey(beatKey: string): Beat {
const foundBeat = this.beats.find(beat => beat.getKey() === beatKey);
if (typeof foundBeat === "undefined") {
throw new Error(`Could not find the beat with key: ${beatKey}`);
}
return foundBeat;
}
getBeatByIndex(beatIndex: number): Beat {
if (!this.beats[beatIndex]) {
throw new Error(`Could not find the beat with index: ${beatIndex}`);
}
return this.beats[beatIndex];
}
getBeatCount(): number {
return this.beats.length;
}
getBeatKeys(): string[] {
return this.beats.map(beat => beat.getKey());
}
swapBeatsByIndices(beatIndex1: number, beatIndex2: number): void {
const beat1 = this.getBeatByIndex(beatIndex1);
const beat2 = this.getBeatByIndex(beatIndex2);
this.beats[beatIndex1] = beat2;
this.beats[beatIndex2] = beat1;
this.publisher.notifySubs(BeatGroupEvents.BeatOrderChanged);
}
moveBeatBack(beatKey: string): void {
const index = this.beats.indexOf(this.getBeatByKey(beatKey));
if (typeof index !== "undefined" && index > 0) {
this.swapBeatsByIndices(index, index - 1);
}
this.publisher.notifySubs(BeatGroupEvents.BeatOrderChanged);
this.publisher.notifySubs(BeatGroupEvents.BeatListChanged);
}
moveBeatForward(beatKey: string): void {
const index = this.beats.indexOf(this.getBeatByKey(beatKey));
if (typeof index !== "undefined" && index < this.getBeatCount()) {
this.swapBeatsByIndices(index, index + 1);
}
this.publisher.notifySubs(BeatGroupEvents.BeatOrderChanged);
this.publisher.notifySubs(BeatGroupEvents.BeatListChanged);
}
canMoveBeatBack(beatKey: string): boolean {
return this.beats.indexOf(this.getBeatByKey(beatKey)) > 0;
}
canMoveBeatForward(beatKey: string): boolean {
return this.beats.indexOf(this.getBeatByKey(beatKey)) < this.beats.length - 1;
}
addBeat(options?: BeatInitOptions): Beat {
options = {
timeSig: {
up: this.timeSigUp,
down: 4,
},
bars: this.barCount,
isLooping: this.globalIsLooping,
loopLength: this.globalLoopLength,
...options
};
const newBeat = new Beat(options);
this.beats.push(newBeat);
newBeat.addSubscriber(this, [
BeatEvents.LoopLengthChanged,
BeatEvents.WantsRemoval,
BeatEvents.DisplayTypeChanged,
BeatEvents.Baked,
]);
this.publisher.notifySubs(BeatGroupEvents.BeatListChanged);
return newBeat;
}
removeBeat(beatKey: string): void {
const beat = this.getBeatByKey(beatKey);
this.beats.splice(this.beats.indexOf(beat), 1);
this.autoBeatLength();
console.log("removing");
this.publisher.notifySubs(BeatGroupEvents.BeatListChanged);
}
setBeatName(beatKey: string, newName: string): void {
this.getBeatByKey(beatKey).setName(newName);
this.publisher.notifySubs(BeatGroupEvents.BeatOrderChanged);
}
autoBeatLengthOn(): boolean {
return this.useAutoBeatLength;
}
private autoBeatLength(): void {
if (this.useAutoBeatLength) {
this.setBarCountInternal(this.findSmallestLoopLength() / this.timeSigUp);
}
}
setIsUsingAutoBeatLength(isOn: boolean): void {
this.useAutoBeatLength = isOn;
this.autoBeatLength();
if (isOn) {
this.lockBars();
} else {
this.unlockBars();
}
this.publisher.notifySubs(BeatGroupEvents.AutoBeatSettingsChanged);
}
barsLocked(): boolean {
return this.barSettingsLocked;
}
lockBars(): void {
this.barSettingsLocked = true;
this.publisher.notifySubs(BeatGroupEvents.LockingChanged);
}
unlockBars(): void {
this.barSettingsLocked = false;
this.publisher.notifySubs(BeatGroupEvents.LockingChanged);
}
bakeLoops(): void {
this.beats.forEach(beat => beat.bakeLoops());
}
setName(newName: string): void {
this.name = newName;
this.publisher.notifySubs(BeatGroupEvents.NameChanged);
}
getName(): string {
return this.name;
}
}

View File

@@ -1,11 +0,0 @@
import {IPublisher} from "./Publisher";
import {BeatEvents} from "./Beat";
export default interface BeatLike extends IPublisher<BeatEvents>{
setBarCount(barCount: number): void;
getBarCount(): void;
setLooping(isLooping: boolean): void;
isLooping(): boolean;
setLoopLength(loopLength: number): void;
getLoopLength(): number;
}

View File

@@ -1,78 +0,0 @@
import {IPublisher, Publisher} from "./Publisher";
import ISubscriber from "./Subscriber";
export const enum BeatUnitType {
Normal="but-0",
GhostNote="but-1",
Accent="but-2",
}
export const enum BeatUnitEvent {
Toggle="bue-0",
On="bue-1",
Off="bue-2",
TypeChange="bue-3",
}
export default class BeatUnit implements IPublisher<BeatUnitEvent> {
private static readonly TypeRotation = [
BeatUnitType.Normal,
BeatUnitType.GhostNote,
BeatUnitType.Accent,
] as const;
private publisher: Publisher<BeatUnitEvent, BeatUnit> = new Publisher<BeatUnitEvent, BeatUnit>(this);
private on = false;
private typeIndex = 0;
constructor(on = false, type = BeatUnitType.Normal) {
this.on = on;
this.setType(type);
}
addSubscriber(subscriber: ISubscriber<BeatUnitEvent>, eventType: BeatUnitEvent[]): { unbind: () => void } {
return this.publisher.addSubscriber(subscriber, eventType);
}
toggle(): void {
this.on = !this.on;
this.publisher.notifySubs(BeatUnitEvent.Toggle);
if (this.on) {
this.publisher.notifySubs(BeatUnitEvent.On);
} else {
this.publisher.notifySubs(BeatUnitEvent.Off);
}
}
setOn(on: boolean): void {
this.on = on;
this.publisher.notifySubs(this.on ? BeatUnitEvent.On : BeatUnitEvent.Off);
}
setType(type: BeatUnitType): void {
this.typeIndex = BeatUnit.TypeRotation.indexOf(type);
this.publisher.notifySubs(BeatUnitEvent.TypeChange);
}
getType(): BeatUnitType {
return BeatUnit.TypeRotation[this.typeIndex];
}
rotateType(): void {
if (this.typeIndex === BeatUnit.TypeRotation.length - 1) {
this.typeIndex = 0;
} else {
this.typeIndex += 1;
}
this.publisher.notifySubs(BeatUnitEvent.TypeChange);
}
isOn(): boolean {
return this.on;
}
mimic(beatUnit: BeatUnit): void {
this.setOn(beatUnit.isOn());
this.setType(beatUnit.getType());
}
}

View File

@@ -1,5 +1,7 @@
import {ISubscription} from "@/Publisher";
export type MaybeRef<T> = T | Ref<T>;
class RefSubscription implements ISubscription {
private unbindCallback?: () => void;
@@ -16,17 +18,27 @@ interface Stringable {
toString(): string;
}
export default class Ref<T extends { toString(): string } | string | null = Stringable> {
type AllowedRef = { toString(): string } | string | null;
export default class Ref<T extends AllowedRef = Stringable> {
private watchers: Array<(newVal: T) => void> | null = null;
private value: T;
private asString?: string;
private isString: boolean;
constructor(val: T) {
private constructor(val: T) {
this.value = val;
this.isString = typeof val === "string";
}
static new<T extends AllowedRef>(val: MaybeRef<T>): Ref<T> {
if (val instanceof Ref) {
return val;
} else {
return new Ref<T>(val);
}
}
watch(watcher: (newVal: T) => void): ISubscription {
if (this.watchers === null) {
this.watchers = [];

10
src/Store.ts Normal file
View File

@@ -0,0 +1,10 @@
import Beat from "@/Beat";
export default class Store {
private beats: Beat[];
constructor() {
this.beats = [];
}
}

168
src/Track.ts Normal file
View File

@@ -0,0 +1,168 @@
import TrackUnit from "@/TrackUnit";
import {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber";
import {isPosInt} from "@/utils";
export type TrackInitOptions = {
timeSig?: {
up: number,
down: number,
},
name?: string,
bars?: number,
isLooping?: boolean,
loopLength?: number,
};
export const enum TrackEvents {
NewTimeSig="be-0",
NewBarCount="be-1",
NewName="be-2",
DisplayTypeChanged="be-3",
LoopLengthChanged="be-4",
WantsRemoval="be-5",
Baked="be-6",
}
export default class Track implements IPublisher<TrackEvents> {
private static count = 0;
private readonly key: string;
private name: string;
private timeSigUp = 4;
private timeSigDown = 4;
private readonly unitRecord: TrackUnit[] = [];
private barCount = 1;
private publisher = new Publisher<TrackEvents, Track>(this);
private loopLength: number;
private looping: boolean;
constructor(options?: TrackInitOptions) {
this.key = `B-${Track.count}`;
this.name = options?.name ?? this.key;
this.setTimeSignature({up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4});
this.setBarCount(options?.bars ?? 4);
Track.count++;
this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount;
this.looping = options?.isLooping ?? false;
}
setLoopLength(loopLength: number): void {
if (!isPosInt(loopLength) || loopLength < 2) {
loopLength = this.loopLength;
}
this.loopLength = loopLength;
this.publisher.notifySubs(TrackEvents.LoopLengthChanged);
}
setLooping(isLooping: boolean): void {
this.looping = isLooping;
this.publisher.notifySubs(TrackEvents.DisplayTypeChanged);
}
addSubscriber(subscriber: ISubscriber<TrackEvents>, eventType: TrackEvents | TrackEvents[]): { unbind: () => void } {
return this.publisher.addSubscriber(subscriber, eventType);
}
setTimeSignature(timeSig: {up?: number, down?: number}): void {
if (timeSig.up && Track.isValidTimeSigRange(timeSig.up)) {
this.timeSigUp = timeSig.up | 0;
}
if (timeSig.down && Track.isValidTimeSigRange(timeSig.down)) {
this.timeSigDown = timeSig.down | 0;
}
this.updateTrackUnitLength();
this.publisher.notifySubs(TrackEvents.NewTimeSig);
}
setTimeSigUp(timeSigUp: number): void {
this.setTimeSignature({up: timeSigUp});
}
setTimeSigDown(timeSigUp: number): void {
this.setTimeSignature({down: timeSigUp});
}
setBarCount(barCount: number): void {
if (!isPosInt(barCount) || barCount == this.barCount) {
barCount = this.barCount;
}
this.barCount = barCount;
this.updateTrackUnitLength();
this.publisher.notifySubs(TrackEvents.NewBarCount);
}
getUnitByIndex(index: number): TrackUnit | null {
if (this.looping) {
index %= this.loopLength;
}
return this.unitRecord[index] ?? null;
}
private updateTrackUnitLength() {
const newBarCount = this.barCount * this.timeSigUp;
if (newBarCount < this.unitRecord.length) {
this.unitRecord.splice(this.barCount * this.timeSigUp, this.unitRecord.length - newBarCount);
} else if (newBarCount > this.unitRecord.length) {
const barsToAdd = newBarCount - this.unitRecord.length;
for (let i = 0; i < barsToAdd; i++) {
this.unitRecord.push(new TrackUnit());
}
}
}
getTimeSigUp(): number {
return this.timeSigUp;
}
getTimeSigDown(): number {
return this.timeSigDown;
}
getBarCount(): number {
return this.barCount;
}
getKey(): string {
return this.key;
}
static isValidTimeSigRange(sig: number): boolean {
return sig >= 2 && sig <= 32;
}
setName(newName: string): void {
this.name = newName;
this.publisher.notifySubs(TrackEvents.NewName);
}
getName(): string {
return this.name;
}
isLooping(): boolean {
return this.looping;
}
getLoopLength(): number {
return this.loopLength;
}
delete(): void {
this.publisher.notifySubs(TrackEvents.WantsRemoval);
}
bakeLoops(): void {
if (this.isLooping()) {
this.unitRecord.forEach((unit, i) => {
const reprUnitAtPos = this.getUnitByIndex(i);
if (reprUnitAtPos) {
unit.mimic(reprUnitAtPos);
}
});
this.publisher.notifySubs(TrackEvents.Baked);
this.setLooping(false);
} else {
this.publisher.notifySubs(TrackEvents.Baked);
}
}
}

78
src/TrackUnit.ts Normal file
View File

@@ -0,0 +1,78 @@
import {IPublisher, Publisher} from "./Publisher";
import ISubscriber from "./Subscriber";
export const enum TrackUnitType {
Normal="but-0",
GhostNote="but-1",
Accent="but-2",
}
export const enum TrackUnitEvent {
Toggle="tue-0",
On="tue-1",
Off="tue-2",
TypeChange="tue-3",
}
export default class TrackUnit implements IPublisher<TrackUnitEvent> {
private static readonly TypeRotation = [
TrackUnitType.Normal,
TrackUnitType.GhostNote,
TrackUnitType.Accent,
] as const;
private publisher: Publisher<TrackUnitEvent, TrackUnit> = new Publisher<TrackUnitEvent, TrackUnit>(this);
private on = false;
private typeIndex = 0;
constructor(on = false, type = TrackUnitType.Normal) {
this.on = on;
this.setType(type);
}
addSubscriber(subscriber: ISubscriber<TrackUnitEvent>, eventType: TrackUnitEvent[]): { unbind: () => void } {
return this.publisher.addSubscriber(subscriber, eventType);
}
toggle(): void {
this.on = !this.on;
this.publisher.notifySubs(TrackUnitEvent.Toggle);
if (this.on) {
this.publisher.notifySubs(TrackUnitEvent.On);
} else {
this.publisher.notifySubs(TrackUnitEvent.Off);
}
}
setOn(on: boolean): void {
this.on = on;
this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off);
}
setType(type: TrackUnitType): void {
this.typeIndex = TrackUnit.TypeRotation.indexOf(type);
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
}
getType(): TrackUnitType {
return TrackUnit.TypeRotation[this.typeIndex];
}
rotateType(): void {
if (this.typeIndex === TrackUnit.TypeRotation.length - 1) {
this.typeIndex = 0;
} else {
this.typeIndex += 1;
}
this.publisher.notifySubs(TrackUnitEvent.TypeChange);
}
isOn(): boolean {
return this.on;
}
mimic(trackUnit: TrackUnit): void {
this.setOn(trackUnit.isOn());
this.setType(trackUnit.getType());
}
}

View File

@@ -1,4 +1,4 @@
import {BeatUnitType} from "./BeatUnit";
import Beat from "./Beat";
import {TrackUnitType} from "./TrackUnit";
import Track from "./Beat";

View File

@@ -1,65 +1,15 @@
.beat > * {
padding-right: 1em;
padding-left: 1em;
}
.vertical-mode .beat > * {
padding-right: 0;
padding-left: 0;
}
.beat-unit-block {
height: 2em;
}
.vertical-mode .beat-unit-block {
height: auto;
width: 2em;
}
.beat-title {
width: 3em;
line-height: 32px;
margin: 0;
}
.vertical-mode .beat-title {
display: block;
width: auto;
text-align: center;
}
.beat-spacer {
display: inline-block;
width: 1em;
height: 2em;
}
.vertical-mode .beat-spacer {
display: block;
width: 2em;
height: 1em;
}
.beat-main {
display: inline-flex;
}
.vertical-mode .beat-main {
width: 2em;
margin-right: 4px;
display: block;
}
.beat-settings-container {
display: flex;
}
.beat {
width: max-content;
margin-bottom: 4px;
padding: 1em;
overflow-x: scroll;
overflow-y: hidden;
display: flex;
width: inherit;
flex-direction: column;
}
.vertical-mode .beat {
display: inline-block;
height: inherit;
overflow-x: hidden;
overflow-y: scroll;
display: block;
}

View File

@@ -1,177 +1,87 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import Beat, {BeatEvents} from "@/Beat";
import ISubscriber from "@/Subscriber";
import BeatUnitView from "@/ui/BeatUnit/BeatUnitView";
import TrackView from "@/ui/Track/TrackView";
import "./Beat.css";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
import Ref from "@/Ref";
export type BeatUINodeOptions = UINodeOptions & {
title: string,
beat: Beat,
orientation?: "horizontal" | "vertical",
};
const EventTypeSubscriptions = [
BeatEvents.NewName,
BeatEvents.NewTimeSig,
BeatEvents.NewBarCount,
BeatEvents.DisplayTypeChanged,
BeatEvents.LoopLengthChanged,
BeatEvents.TrackListChanged
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class BeatView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private beat!: Beat;
private title = new Ref<HTMLHeadingElement | null>(null);
private beatUnitViews: BeatUnitView[] = [];
private beatUnitViewBlock: HTMLElement | null = null;
private lastHoveredBeatUnitView: BeatUnitView | null = null;
private sub: ISubscription | null = null;
static deselectingUnits = false;
static selectingUnits = false;
private title: string;
private beat: Beat;
private trackViews: TrackView[] = [];
private currentOrientation: "vertical" | "horizontal";
private subscription: ISubscription;
constructor(options: BeatUINodeOptions) {
super(options);
this.setBeat(options.beat);
}
setBeat(beat: Beat | null): void {
if (beat) {
this.beat = beat;
this.sub?.unbind();
this.sub = this.beat.addSubscriber(this, EventTypeSubscriptions);
this.redraw();
} else {
this.sub?.unbind();
}
this.beat = options.beat;
this.title = options.title;
this.currentOrientation = options.orientation ?? "horizontal";
this.subscription = this.beat.addSubscriber(this, EventTypeSubscriptions);
this.setupBeatViews();
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case BeatEvents.NewName:
this.title.val!.innerText = this.beat.getName();
break;
case BeatEvents.NewTimeSig:
case BeatEvents.NewBarCount:
case BeatEvents.DisplayTypeChanged:
case BeatEvents.LoopLengthChanged:
this.setupBeatUnits();
break;
if (event === BeatEvents.TrackListChanged) {
this.setupBeatViews();
this.redraw();
}
}
private rebuildBeatUnitViews() {
const beatUnitCount = this.beat.getBarCount() * this.beat.getTimeSigUp();
for (let i = 0; i < beatUnitCount; i++) {
const beatUnit = this.beat.getUnitByIndex(i);
if (beatUnit) {
let view: BeatUnitView;
if (this.beatUnitViews[i]) {
view = this.beatUnitViews[i];
view.setUnit(beatUnit);
private setupBeatViews(): void {
const newCount = this.beat.getTrackCount();
for (let i = 0; i < newCount; i++) {
const beat = this.beat.getTrackByIndex(i);
if (beat && this.trackViews[i]) {
this.trackViews[i].setBeat(beat);
} else {
view = new BeatUnitView({beatUnit});
this.beatUnitViews.push(view);
view.onHover(() => this.onBeatViewHover(view));
view.onMouseDown((event: MouseEvent) => this.onBeatUnitClick(event.button, i));
this.trackViews.push(new TrackView({track: this.beat.getTrackByIndex(i)}));
}
}
}
const deadViews = this.beatUnitViews.splice(beatUnitCount, this.beatUnitViews.length - beatUnitCount);
deadViews.forEach(beatUnitView => beatUnitView.setUnit(null));
}
private onBeatUnitClick(button: number, index: number) {
if (button === 0) {
BeatView.selectingUnits = true;
this.beat.getUnitByIndex(index)?.toggle();
} else if (button === 2) {
BeatView.deselectingUnits = true;
this.beat.getUnitByIndex(index)?.setOn(false);
const deadTrackViews = this.trackViews.splice(newCount, this.trackViews.length - newCount);
deadTrackViews.forEach(beatView => beatView.setBeat(null));
if (this.currentOrientation === "horizontal") {
this.reverseDisplayOrder();
}
}
private onBeatViewHover(beatView: BeatUnitView) {
this.lastHoveredBeatUnitView = beatView;
if (BeatView.selectingUnits) {
this.lastHoveredBeatUnitView.turnOn();
} else if (BeatView.deselectingUnits) {
this.lastHoveredBeatUnitView.turnOff();
setOrientation(orientation: "vertical" | "horizontal"): void {
if (this.currentOrientation !== orientation) {
this.reverseDisplayOrder();
this.currentOrientation = orientation;
}
}
private buildBeatUnitViewBlock(): void {
const beatUnitNodes: HTMLElement[] = [];
for (let i = 0; i < this.beatUnitViews.length; i++) {
beatUnitNodes.push(this.beatUnitViews[i].render());
}
if (this.beatUnitViewBlock) {
this.beatUnitViewBlock.replaceChildren(...beatUnitNodes);
} else {
this.beatUnitViewBlock = h("div", {
classes: ["beat-unit-block"],
}, [
...beatUnitNodes
]);
}
private reverseDisplayOrder(): void {
this.trackViews.reverse();
this.getNode().classList.toggle("vertical");
this.redraw();
}
private respaceBeatUnits(): void {
if (!this.beatUnitViewBlock) {
return;
}
this.beatUnitViewBlock.querySelectorAll(".beat-spacer").forEach(spacer => spacer.remove());
const barLength = this.beat.getTimeSigUp();
const barCount = this.beat.getBarCount();
let bars = 0;
let i = -1;
let spacersInserted = false;
while (!spacersInserted) {
i += barLength;
const newSpacer = h("div", {classes: ["beat-spacer"]});
const leftNeighbour = this.beatUnitViewBlock.children.item(i);
if (leftNeighbour) {
leftNeighbour.insertAdjacentElement("afterend", newSpacer);
} else {
break;
}
i++;
bars++;
if (bars === barCount) {
spacersInserted = true;
}
}
setBeatGroup(newBeatGroup: Beat): void {
this.beat = newBeatGroup;
this.subscription.unbind();
this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged);
this.setupBeatViews();
this.redraw();
}
private setupBeatUnits(): void {
this.rebuildBeatUnitViews();
this.buildBeatUnitViewBlock();
this.respaceBeatUnits();
}
build(): HTMLElement {
this.setupBeatUnits();
if (!this.beatUnitViewBlock) {
throw new Error("Beat unit block setup failed!");
}
build(): HTMLDivElement {
return h("div", {
classes: ["beat"],
},[
h("div", {
classes: ["beat-main"],
}, [
h("h3", {
innerText: this.beat.getName(),
saveTo: this.title,
classes: ["beat-title"],
}),
this.beatUnitViewBlock,
]),
...this.trackViews
]);
}
}
window.addEventListener("mouseup", () => {
BeatView.selectingUnits = false;
BeatView.deselectingUnits = false;
});

View File

@@ -1,15 +0,0 @@
.beat-group {
padding: 1em;
overflow-x: scroll;
overflow-y: hidden;
display: flex;
width: inherit;
flex-direction: column;
}
.vertical-mode .beat-group {
height: inherit;
overflow-x: hidden;
overflow-y: scroll;
display: block;
}

View File

@@ -1,87 +0,0 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import BeatGroup, {BeatGroupEvents} from "@/BeatGroup";
import BeatView from "@/ui/Beat/BeatView";
import "./BeatGroup.css";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
export type BeatGroupUINodeOptions = UINodeOptions & {
title: string,
beatGroup: BeatGroup,
orientation?: "horizontal" | "vertical",
};
const EventTypeSubscriptions = [
BeatGroupEvents.BeatListChanged
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class BeatGroupView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private title: string;
private beatGroup: BeatGroup;
private beatViews: BeatView[] = [];
private currentOrientation: "vertical" | "horizontal";
private subscription: ISubscription;
constructor(options: BeatGroupUINodeOptions) {
super(options);
this.beatGroup = options.beatGroup;
this.title = options.title;
this.currentOrientation = options.orientation ?? "horizontal";
this.subscription = this.beatGroup.addSubscriber(this, EventTypeSubscriptions);
this.setupBeatViews();
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
if (event === BeatGroupEvents.BeatListChanged) {
this.setupBeatViews();
this.redraw();
}
}
private setupBeatViews(): void {
const newCount = this.beatGroup.getBeatCount();
for (let i = 0; i < newCount; i++) {
const beat = this.beatGroup.getBeatByIndex(i);
if (beat && this.beatViews[i]) {
this.beatViews[i].setBeat(beat);
} else {
this.beatViews.push(new BeatView({beat: this.beatGroup.getBeatByIndex(i)}));
}
}
const deadBeatViews = this.beatViews.splice(newCount, this.beatViews.length - newCount);
deadBeatViews.forEach(beatView => beatView.setBeat(null));
if (this.currentOrientation === "horizontal") {
this.reverseDisplayOrder();
}
}
setOrientation(orientation: "vertical" | "horizontal"): void {
if (this.currentOrientation !== orientation) {
this.reverseDisplayOrder();
this.currentOrientation = orientation;
}
}
private reverseDisplayOrder(): void {
this.beatViews.reverse();
this.getNode().classList.toggle("vertical");
this.redraw();
}
setBeatGroup(newBeatGroup: BeatGroup): void {
this.beatGroup = newBeatGroup;
this.subscription.unbind();
this.subscription = this.beatGroup.addSubscriber(this, BeatGroupEvents.BeatListChanged);
this.setupBeatViews();
this.redraw();
}
build(): HTMLDivElement {
return h("div", {
classes: ["beat-group"],
},[
...this.beatViews
]);
}
}

View File

@@ -1,20 +0,0 @@
.beat-group-settings {
}
.beat-group-settings-options {
padding: 1em;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.beat-group-settings-option {
text-align: center;
}
.beat-group-settings-option-group {
display: none;
}
.beat-group-settings-option-group.visible {
display: inline-block;
}

View File

@@ -1,140 +0,0 @@
import "./BeatGroupSettings.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
import ISubscriber from "@/Subscriber";
import BeatGroup, {BeatGroupEvents} from "@/BeatGroup";
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView";
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
export type BeatGroupSettingsUINodeOptions = UINodeOptions & {
beatGroup: BeatGroup,
};
const EventTypeSubscriptions = [
BeatGroupEvents.BarCountChanged,
BeatGroupEvents.TimeSigUpChanged,
BeatGroupEvents.GlobalDisplayTypeChanged,
BeatGroupEvents.BeatListChanged,
BeatGroupEvents.LockingChanged,
BeatGroupEvents.AutoBeatSettingsChanged,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class BeatGroupSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private beatGroup: BeatGroup;
private barCountInput!: NumberInputView;
private timeSigUpInput!: NumberInputView;
private autoBeatLengthCheckbox!: BoolBoxView;
private beatSettingsViews: BeatSettingsView[] = [];
private beatSettingsContainer!: HTMLDivElement;
constructor(options: BeatGroupSettingsUINodeOptions) {
super(options);
this.beatGroup = options.beatGroup;
this.setupBindings();
}
setBeatGroup(newBeatGroup: BeatGroup): void {
this.beatGroup = newBeatGroup;
this.setupBindings();
EventTypeSubscriptions.forEach(eventType => this.notify(null, eventType));
}
setupBindings(): void {
this.beatGroup.addSubscriber(this, EventTypeSubscriptions);
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch(event) {
case BeatGroupEvents.BarCountChanged:
this.barCountInput.setValue(this.beatGroup.getBarCount());
break;
case BeatGroupEvents.TimeSigUpChanged:
this.timeSigUpInput.setValue(this.beatGroup.getTimeSigUp());
break;
case BeatGroupEvents.BeatListChanged:
this.remakeBeatSettingsViews();
break;
case BeatGroupEvents.LockingChanged:
if (this.beatGroup.barsLocked()) {
this.barCountInput.disable();
} else {
this.barCountInput.enable();
}
break;
case BeatGroupEvents.AutoBeatSettingsChanged:
this.autoBeatLengthCheckbox.setValue(this.beatGroup.autoBeatLengthOn());
break;
case BeatGroupEvents.GlobalDisplayTypeChanged:
break;
}
}
private remakeBeatSettingsViews() {
const beatCount = this.beatGroup.getBeatCount();
this.beatSettingsViews.splice(beatCount, this.beatSettingsViews.length - beatCount);
for (let i = 0; i < beatCount; i++) {
if (this.beatSettingsViews[i]) {
this.beatSettingsViews[i].setBeat(this.beatGroup.getBeatByIndex(i));
} else {
this.beatSettingsViews.push(new BeatSettingsView({ beat: this.beatGroup.getBeatByIndex(i) }));
}
}
if (!this.beatSettingsContainer) {
this.beatSettingsContainer = h("div", {}, this.beatSettingsViews);
} else {
this.beatSettingsContainer.replaceChildren(...this.beatSettingsViews.reverse().map(view => view.render()));
}
}
build(): HTMLElement {
this.barCountInput = new NumberInputView({
label: "Bars:",
initialValue: this.beatGroup.getBarCount(),
setter: (input: number) => this.beatGroup.setBarCount(input),
getter: () => this.beatGroup.getBarCount(),
});
this.timeSigUpInput = new NumberInputView({
label: "Boxes per bar:",
initialValue: this.beatGroup.getTimeSigUp(),
setter: (input: number) => this.beatGroup.setTimeSigUp(input),
getter: () => this.beatGroup.getTimeSigUp(),
});
this.autoBeatLengthCheckbox = new BoolBoxView({
label: "Auto beat length:",
value: this.beatGroup.autoBeatLengthOn(),
onInput: (isChecked: boolean) => this.beatGroup.setIsUsingAutoBeatLength(isChecked),
});
this.remakeBeatSettingsViews();
return h("div", {
classes: ["beat-group-settings"],
}, [
h("div", {
classes: ["beat-group-settings-options"],
}, [
h("div", {
classes: ["beat-group-settings-boxes", "beat-group-settings-option"],
}, [
this.timeSigUpInput,
]),
h("div", {
classes: ["beat-group-settings-bar-count", "beat-group-settings-option"]
,
}, [
this.barCountInput,
]),
h("div", {
classes: ["beat-group-settings-bar-count", "beat-group-settings-option"],
}, [
this.autoBeatLengthCheckbox,
]),
new ActionButtonView({
label: "New Track",
onClick: () => this.beatGroup.addBeat(),
}),
this.beatSettingsContainer,
]),
]);
}
}

View File

@@ -1,54 +1,20 @@
.beat-settings {
}
.beat-settings-title-container {
}
.beat-settings-title-container input {
min-width: 100%;
height: 2em;
}
.beat-settings-title-container > div {
width: 100%;
font-weight: bold;
padding: 0.5em;
transition: background-color 200ms;
cursor: pointer;
}
.beat-settings-title-container > div:hover {
background-color: var(--color-ui-neutral-dark-hover);
}
.beat-settings-lower {
height: 3.5em;
.beat-settings-options {
padding: 1em;
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.beat-settings-option {
text-align: center;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5em;
}
.beat-settings-lower > * {
margin-right: 0.2em;
}
.beat-settings-lower:last-child {
margin-right: 0;
}
.beat-settings .loop-settings {
text-align: left;
flex: auto;
}
.beat-settings .loop-settings-option.hide {
.beat-settings-option-group {
display: none;
}
.beat-settings .loop-settings-option {
flex: auto;
padding-right: 1em;
.beat-settings-option-group.visible {
display: inline-block;
}

View File

@@ -1,129 +1,139 @@
import "./BeatSettings.css";
import Beat, {BeatEvents} from "@/Beat";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
import ISubscriber from "@/Subscriber";
import Beat, {BeatEvents} from "@/Beat";
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
import TrackSettingsView from "@/ui/TrackSettings/TrackSettingsView";
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
export type BeatSettingsViewUINodeOptions = UINodeOptions & {
export type BeatSettingsUINodeOptions = UINodeOptions & {
beat: Beat,
};
const EventTypeSubscriptions = [
BeatEvents.NewName,
BeatEvents.LoopLengthChanged,
BeatEvents.DisplayTypeChanged,
BeatEvents.TimeSigUpChanged,
BeatEvents.BarCountChanged,
BeatEvents.GlobalDisplayTypeChanged,
BeatEvents.TrackListChanged,
BeatEvents.LockingChanged,
BeatEvents.AutoBeatSettingsChanged,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class BeatSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private beat: Beat;
private loopLengthInput!: NumberInputView;
private bakeButton!: ActionButtonView;
private loopCheckbox!: BoolBoxView;
private loopLengthSection!: HTMLDivElement;
private sub!: ISubscription;
private title!: EditableTextFieldView;
private editingTitle: boolean;
private barCountInput!: NumberInputView;
private timeSigUpInput!: NumberInputView;
private autoBeatLengthCheckbox!: BoolBoxView;
private trackSettingsViews: TrackSettingsView[] = [];
private trackSettingsContainer!: HTMLDivElement;
constructor(options: BeatSettingsViewUINodeOptions) {
constructor(options: BeatSettingsUINodeOptions) {
super(options);
this.editingTitle = false;
this.beat = options.beat;
this.setupBindings();
}
private setupBindings() {
this.sub = this.beat.addSubscriber(this, EventTypeSubscriptions);
}
setBeat(beat: Beat): void {
this.sub.unbind();
this.beat = beat;
setBeatGroup(newBeat: Beat): void {
this.beat = newBeat;
this.setupBindings();
EventTypeSubscriptions.forEach(eventType => this.notify(null, eventType));
}
setupBindings(): void {
this.beat.addSubscriber(this, EventTypeSubscriptions);
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch(event) {
case BeatEvents.NewName:
this.title.setText(this.beat.getName());
case BeatEvents.BarCountChanged:
this.barCountInput.setValue(this.beat.getBarCount());
break;
case BeatEvents.LoopLengthChanged:
this.loopLengthInput.setValue(this.beat.getLoopLength());
case BeatEvents.TimeSigUpChanged:
this.timeSigUpInput.setValue(this.beat.getTimeSigUp());
break;
case BeatEvents.DisplayTypeChanged:
this.loopCheckbox.setValue(this.beat.isLooping());
this.bakeButton.setDisabled(!this.beat.isLooping());
if (this.beat.isLooping()) {
this.loopLengthSection.classList.remove("hide");
case BeatEvents.TrackListChanged:
this.remakeBeatSettingsViews();
break;
case BeatEvents.LockingChanged:
if (this.beat.barsLocked()) {
this.barCountInput.disable();
} else {
this.loopLengthSection.classList.add("hide");
this.barCountInput.enable();
}
break;
case BeatEvents.AutoBeatSettingsChanged:
this.autoBeatLengthCheckbox.setValue(this.beat.autoBeatLengthOn());
break;
case BeatEvents.GlobalDisplayTypeChanged:
break;
}
}
build(): HTMLElement {
this.title = new EditableTextFieldView({
initialText: this.beat.getName(),
setter: (newText) => this.beat.setName(newText),
});
this.bakeButton = new ActionButtonView({
icon: "snowflake",
type: "secondary",
alt: "Bake Loops",
disabled: !this.beat.isLooping(),
onClick: () => this.beat.bakeLoops(),
});
this.loopLengthInput = new NumberInputView({
initialValue: this.beat.getLoopLength(),
onDecrement: () => this.beat.setLoopLength(this.beat.getLoopLength() - 1),
onIncrement: () => this.beat.setLoopLength(this.beat.getLoopLength() + 1),
onNewInput: (input: number) => this.beat.setLoopLength(input),
});
this.loopCheckbox = new BoolBoxView({
label: "Loop:",
value: this.beat.isLooping(),
onInput: (isChecked: boolean) => this.beat.setLooping(isChecked),
});
this.loopLengthSection = h("div", {
classes: ["loop-settings-option"],
}, [
this.loopLengthInput,
]);
if (this.beat.isLooping()) {
this.loopLengthSection.classList.remove("hide");
private remakeBeatSettingsViews() {
const trackCount = this.beat.getTrackCount();
this.trackSettingsViews.splice(trackCount, this.trackSettingsViews.length - trackCount);
for (let i = 0; i < trackCount; i++) {
if (this.trackSettingsViews[i]) {
this.trackSettingsViews[i].setBeat(this.beat.getTrackByIndex(i));
} else {
this.loopLengthSection.classList.add("hide");
this.trackSettingsViews.push(new TrackSettingsView({ track: this.beat.getTrackByIndex(i) }));
}
}
if (!this.trackSettingsContainer) {
this.trackSettingsContainer = h("div", {}, this.trackSettingsViews);
} else {
this.trackSettingsContainer.replaceChildren(...this.trackSettingsViews.reverse().map(view => view.render()));
}
}
build(): HTMLElement {
this.barCountInput = new NumberInputView({
label: "Bars:",
initialValue: this.beat.getBarCount(),
setter: (input: number) => this.beat.setBarCount(input),
getter: () => this.beat.getBarCount(),
});
this.timeSigUpInput = new NumberInputView({
label: "Boxes per bar:",
initialValue: this.beat.getTimeSigUp(),
setter: (input: number) => this.beat.setTimeSigUp(input),
getter: () => this.beat.getTimeSigUp(),
});
this.autoBeatLengthCheckbox = new BoolBoxView({
label: "Auto beat length:",
value: this.beat.autoBeatLengthOn(),
onInput: (isChecked: boolean) => this.beat.setIsUsingAutoBeatLength(isChecked),
});
this.remakeBeatSettingsViews();
return h("div", {
classes: ["beat-settings"],
}, [
h("div", {
classes: ["beat-settings-title-container"]
classes: ["beat-settings-options"],
}, [
this.title,
h("div", {
classes: ["beat-settings-boxes", "beat-settings-option"],
}, [
this.timeSigUpInput,
]),
h("div", {
classes: ["beat-settings-lower"],
classes: ["beat-settings-bar-count", "beat-settings-option"]
,
}, [
this.bakeButton,
this.barCountInput,
]),
h("div", {
classes: ["beat-settings-bar-count", "beat-settings-option"],
}, [
this.autoBeatLengthCheckbox,
]),
new ActionButtonView({
icon: "trash",
type: "secondary",
alt: "Delete Track",
onClick: () => this.beat.delete(),
label: "New Track",
onClick: () => this.beat.addTrack(),
}),
h("div", {
classes: ["loop-settings"],
}, [
this.loopCheckbox,
]),
this.loopLengthSection,
this.trackSettingsContainer,
]),
]);
}

View File

@@ -1,39 +1,39 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import BeatGroupView from "@/ui/BeatGroup/BeatGroupView";
import BeatGroup from "@/BeatGroup";
import BeatView from "@/ui/Beat/BeatView";
import Beat from "@/Beat";
import "./Root.css";
import BeatGroupSettingsView from "@/ui/BeatGroupSettings/BeatGroupSettingsView";
import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView";
import IconView from "@/ui/Widgets/Icon/IconView";
import StageTitleBarView from "@/ui/StageTitleBar/StageTitleBarView";
import Ref from "@/Ref";
export type RootUINodeOptions = UINodeOptions & {
title: string,
mainBeatGroup?: BeatGroup,
mainBeat?: Beat,
orientation?: "horizontal" | "vertical",
};
export default class RootView extends UINode {
private title: string;
private beatGroupView: BeatGroupView;
private focusedBeatGroup: BeatGroup;
private beatGroupSettingsView: BeatGroupSettingsView;
private beatView: BeatView;
private focusedBeat: Beat;
private beatSettingsView: BeatSettingsView;
private currentOrientation: "horizontal" | "vertical";
private stageTitleBarView: StageTitleBarView;
private showHideSidebarButton: Ref<HTMLDivElement | null> = new Ref<HTMLDivElement | null>(null);
private showHideSidebarButton: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
private sidebarActive = true;
constructor(options: RootUINodeOptions) {
super(options);
this.currentOrientation = options.orientation ?? "horizontal";
this.focusedBeatGroup = options.mainBeatGroup ?? RootView.defaultMainBeatGroup();
this.beatGroupView = new BeatGroupView({
this.focusedBeat = options.mainBeat ?? RootView.defaultMainBeatGroup();
this.beatView = new BeatView({
title: options.title,
beatGroup: this.focusedBeatGroup,
beat: this.focusedBeat,
orientation: this.currentOrientation,
});
this.stageTitleBarView = new StageTitleBarView({beatGroup: this.focusedBeatGroup});
this.beatGroupSettingsView = new BeatGroupSettingsView({beatGroup: this.focusedBeatGroup});
this.stageTitleBarView = new StageTitleBarView({beat: this.focusedBeat});
this.beatSettingsView = new BeatSettingsView({beat: this.focusedBeat});
this.title = options.title;
this.setOrientation(this.currentOrientation);
this.openSidebarForDesktop();
@@ -46,25 +46,25 @@ export default class RootView extends UINode {
}
}
static defaultMainBeatGroup(): BeatGroup {
static defaultMainBeatGroup(): Beat {
const defaultSettings = {
barCount: 2,
isLooping: false,
timeSigUp: 8,
};
const mainBeatGroup = new BeatGroup(defaultSettings);
mainBeatGroup.addBeat({name: "LF"});
mainBeatGroup.addBeat({name: "LH"});
mainBeatGroup.addBeat({name: "RH"});
mainBeatGroup.addBeat({name: "RF"});
const mainBeatGroup = new Beat(defaultSettings);
mainBeatGroup.addTrack({name: "LF"});
mainBeatGroup.addTrack({name: "LH"});
mainBeatGroup.addTrack({name: "RH"});
mainBeatGroup.addTrack({name: "RF"});
return mainBeatGroup;
}
setMainBeatGroup(beatGroup: BeatGroup): void {
this.focusedBeatGroup = beatGroup;
this.beatGroupSettingsView.setBeatGroup(this.focusedBeatGroup);
this.beatGroupView.setBeatGroup(this.focusedBeatGroup);
this.stageTitleBarView.setBeatGroup(this.focusedBeatGroup);
setMainBeatGroup(beat: Beat): void {
this.focusedBeat = beat;
this.beatSettingsView.setBeatGroup(this.focusedBeat);
this.beatView.setBeatGroup(this.focusedBeat);
this.stageTitleBarView.setBeat(this.focusedBeat);
}
toggleSidebar(): void {
@@ -88,7 +88,7 @@ export default class RootView extends UINode {
} else {
this.getNode().classList.remove("vertical-mode");
}
this.beatGroupView.setOrientation(orientation);
this.beatView.setOrientation(orientation);
}
private sidebarText(): string {
@@ -124,7 +124,7 @@ export default class RootView extends UINode {
h("div", {
classes: ["root-quick-access-button"],
title: "Bake all tracks",
onclick: () => this.focusedBeatGroup.bakeLoops(),
onclick: () => this.focusedBeat.bakeLoops(),
}, [
new IconView({
iconName: "snowflake",
@@ -149,7 +149,7 @@ export default class RootView extends UINode {
h("div", {classes: ["root-sidebar"]}, [
h("div", {classes: ["root-settings"]}, [
h("h1", {classes: ["root-title"], innerText: this.title}),
this.beatGroupSettingsView,
this.beatSettingsView,
]),
this.buildSidebarStrip(),
])
@@ -163,7 +163,7 @@ export default class RootView extends UINode {
h("div", {classes: ["root-beat-stage-container"]}, [
this.stageTitleBarView,
h("div", {classes: ["root-beat-stage"]}, [
this.beatGroupView,
this.beatView,
])
])
])

View File

@@ -11,11 +11,6 @@
align-items: center;
}
.stage-title-bar-preamble {
margin-bottom: 4px;
font-size: 12px;
}
.stage-title-bar * {
flex: 1;
}

View File

@@ -1,50 +1,54 @@
import "./StageTitleBar.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import {ISubscription} from "@/Publisher";
import BeatGroup, {BeatGroupEvents} from "@/BeatGroup";
import Beat, {BeatEvents} from "@/Beat";
import ISubscriber from "@/Subscriber";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
import DropdownView, {DropdownViewOption} from "@/ui/Widgets/Dropdown/DropdownView";
import Ref from "@/Ref";
export type StageTitleBarViewOptions = UINodeOptions & {
beatGroup: BeatGroup,
beat: Beat,
};
const EventTypeSubscription = [BeatGroupEvents.NameChanged];
const EventTypeSubscription = [BeatEvents.NameChanged];
type EventTypeSubscription = FlatArray<typeof EventTypeSubscription, 1>;
export default class StageTitleBarView extends UINode implements ISubscriber<EventTypeSubscription> {
private sub: ISubscription;
private beatGroup: BeatGroup;
private beat: Beat;
private title: EditableTextFieldView;
private options: Ref<DropdownViewOption[]>;
constructor(options: StageTitleBarViewOptions) {
super(options);
this.beatGroup = options.beatGroup;
this.sub = options.beatGroup.addSubscriber(this, EventTypeSubscription);
this.beat = options.beat;
this.sub = options.beat.addSubscriber(this, EventTypeSubscription);
this.title = new EditableTextFieldView({
initialText: this.beatGroup.getName(),
setter: (text) => this.beatGroup.setName(text),
initialText: this.beat.getName(),
setter: (text) => this.beat.setName(text),
noEmpty: true,
});
this.options = Ref.new<DropdownViewOption[]>([]);
}
notify(publisher: unknown, event: EventTypeSubscription): void {
if (event === BeatGroupEvents.NameChanged) {
this.title.setText(this.beatGroup.getName());
if (event === BeatEvents.NameChanged) {
this.title.setText(this.beat.getName());
}
}
setBeatGroup(beatGroup: BeatGroup): void {
setBeat(beat: Beat): void {
this.sub.unbind();
this.beatGroup = beatGroup;
this.sub = beatGroup.addSubscriber(this, EventTypeSubscription);
this.notify(this, BeatGroupEvents.NameChanged);
this.beat = beat;
this.sub = beat.addSubscriber(this, EventTypeSubscription);
this.notify(this, BeatEvents.NameChanged);
}
protected build(): HTMLElement {
return h("div", {classes: ["stage-title-bar"]}, [
h("div", {classes: ["stage-title-bar-preamble"], innerText: "Currently editing:"}),
h("h2", {}, [this.title]),
new DropdownView({options: this.options})
]);
}
}

65
src/ui/Track/Track.css Normal file
View File

@@ -0,0 +1,65 @@
.track > * {
padding-right: 1em;
padding-left: 1em;
}
.vertical-mode .track > * {
padding-right: 0;
padding-left: 0;
}
.track-unit-block {
height: 2em;
}
.vertical-mode .track-unit-block {
height: auto;
width: 2em;
}
.track-title {
width: 3em;
line-height: 32px;
margin: 0;
}
.vertical-mode .track-title {
display: block;
width: auto;
text-align: center;
}
.track-spacer {
display: inline-block;
width: 1em;
height: 2em;
}
.vertical-mode .track-spacer {
display: block;
width: 2em;
height: 1em;
}
.track-main {
display: inline-flex;
}
.vertical-mode .track-main {
width: 2em;
margin-right: 4px;
display: block;
}
.track-settings-container {
display: flex;
}
.track {
width: max-content;
margin-bottom: 4px;
}
.vertical-mode .track {
display: inline-block;
}

177
src/ui/Track/TrackView.ts Normal file
View File

@@ -0,0 +1,177 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import Track, {TrackEvents} from "@/Track";
import ISubscriber from "@/Subscriber";
import TrackUnitView from "@/ui/TrackUnit/TrackUnitView";
import "./Track.css";
import {ISubscription} from "@/Publisher";
import Ref from "@/Ref";
export type TrackUINodeOptions = UINodeOptions & {
track: Track,
};
const EventTypeSubscriptions = [
TrackEvents.NewName,
TrackEvents.NewTimeSig,
TrackEvents.NewBarCount,
TrackEvents.DisplayTypeChanged,
TrackEvents.LoopLengthChanged,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private track!: Track;
private title = Ref.new<HTMLHeadingElement | null>(null);
private trackUnitViews: TrackUnitView[] = [];
private trackUnitViewBlock: HTMLElement | null = null;
private lastHoveredTrackUnitView: TrackUnitView | null = null;
private sub: ISubscription | null = null;
static deselectingUnits = false;
static selectingUnits = false;
constructor(options: TrackUINodeOptions) {
super(options);
this.setBeat(options.track);
}
setBeat(track: Track | null): void {
if (track) {
this.track = track;
this.sub?.unbind();
this.sub = this.track.addSubscriber(this, EventTypeSubscriptions);
this.redraw();
} else {
this.sub?.unbind();
}
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case TrackEvents.NewName:
this.title.val!.innerText = this.track.getName();
break;
case TrackEvents.NewTimeSig:
case TrackEvents.NewBarCount:
case TrackEvents.DisplayTypeChanged:
case TrackEvents.LoopLengthChanged:
this.setupTrackUnits();
break;
}
}
private rebuildTrackUnitViews() {
const trackUnitCount = this.track.getBarCount() * this.track.getTimeSigUp();
for (let i = 0; i < trackUnitCount; i++) {
const trackUnit = this.track.getUnitByIndex(i);
if (trackUnit) {
let view: TrackUnitView;
if (this.trackUnitViews[i]) {
view = this.trackUnitViews[i];
view.setUnit(trackUnit);
} else {
view = new TrackUnitView({trackUnit});
this.trackUnitViews.push(view);
view.onHover(() => this.onBeatViewHover(view));
view.onMouseDown((event: MouseEvent) => this.onTrackUnitClick(event.button, i));
}
}
}
const deadViews = this.trackUnitViews.splice(trackUnitCount, this.trackUnitViews.length - trackUnitCount);
deadViews.forEach(trackUnitView => trackUnitView.setUnit(null));
}
private onTrackUnitClick(button: number, index: number) {
if (button === 0) {
TrackView.selectingUnits = true;
this.track.getUnitByIndex(index)?.toggle();
} else if (button === 2) {
TrackView.deselectingUnits = true;
this.track.getUnitByIndex(index)?.setOn(false);
}
}
private onBeatViewHover(trackView: TrackUnitView) {
this.lastHoveredTrackUnitView = trackView;
if (TrackView.selectingUnits) {
this.lastHoveredTrackUnitView.turnOn();
} else if (TrackView.deselectingUnits) {
this.lastHoveredTrackUnitView.turnOff();
}
}
private buildTrackUnitViewBlock(): void {
const trackUnitNodes: HTMLElement[] = [];
for (let i = 0; i < this.trackUnitViews.length; i++) {
trackUnitNodes.push(this.trackUnitViews[i].render());
}
if (this.trackUnitViewBlock) {
this.trackUnitViewBlock.replaceChildren(...trackUnitNodes);
} else {
this.trackUnitViewBlock = h("div", {
classes: ["track-unit-block"],
}, [
...trackUnitNodes
]);
}
}
private respaceTrackUnits(): void {
if (!this.trackUnitViewBlock) {
return;
}
this.trackUnitViewBlock.querySelectorAll(".unit-spacer").forEach(spacer => spacer.remove());
const barLength = this.track.getTimeSigUp();
const barCount = this.track.getBarCount();
let bars = 0;
let i = -1;
let spacersInserted = false;
while (!spacersInserted) {
i += barLength;
const newSpacer = h("div", {classes: ["track-spacer"]});
const leftNeighbour = this.trackUnitViewBlock.children.item(i);
if (leftNeighbour) {
leftNeighbour.insertAdjacentElement("afterend", newSpacer);
} else {
break;
}
i++;
bars++;
if (bars === barCount) {
spacersInserted = true;
}
}
}
private setupTrackUnits(): void {
this.rebuildTrackUnitViews();
this.buildTrackUnitViewBlock();
this.respaceTrackUnits();
}
build(): HTMLElement {
this.setupTrackUnits();
if (!this.trackUnitViewBlock) {
throw new Error("Beat unit block setup failed!");
}
return h("div", {
classes: ["track"],
}, [
h("div", {
classes: ["track-main"],
}, [
h("h3", {
innerText: this.track.getName(),
saveTo: this.title,
classes: ["track-title"],
}),
this.trackUnitViewBlock,
]),
]);
}
}
window.addEventListener("mouseup", () => {
TrackView.selectingUnits = false;
TrackView.deselectingUnits = false;
});

View File

@@ -0,0 +1,54 @@
.track-settings {
}
.track-settings-title-container {
}
.track-settings-title-container input {
min-width: 100%;
height: 2em;
}
.track-settings-title-container > div {
width: 100%;
font-weight: bold;
padding: 0.5em;
transition: background-color 200ms;
cursor: pointer;
}
.track-settings-title-container > div:hover {
background-color: var(--color-ui-neutral-dark-hover);
}
.track-settings-lower {
height: 3.5em;
display: flex;
text-align: center;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5em;
}
.track-settings-lower > * {
margin-right: 0.2em;
}
.track-settings-lower:last-child {
margin-right: 0;
}
.track-settings .loop-settings {
text-align: left;
flex: auto;
}
.track-settings .loop-settings-option.hide {
display: none;
}
.track-settings .loop-settings-option {
flex: auto;
padding-right: 1em;
}

View File

@@ -0,0 +1,130 @@
import "./TrackSettings.css";
import Track, {TrackEvents} from "@/Track";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
export type BeatSettingsViewUINodeOptions = UINodeOptions & {
track: Track,
};
const EventTypeSubscriptions = [
TrackEvents.NewName,
TrackEvents.LoopLengthChanged,
TrackEvents.DisplayTypeChanged,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class TrackSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private track: Track;
private loopLengthInput!: NumberInputView;
private bakeButton!: ActionButtonView;
private loopCheckbox!: BoolBoxView;
private loopLengthSection!: HTMLDivElement;
private sub!: ISubscription;
private title!: EditableTextFieldView;
private editingTitle: boolean;
constructor(options: BeatSettingsViewUINodeOptions) {
super(options);
this.editingTitle = false;
this.track = options.track;
this.setupBindings();
}
private setupBindings() {
this.sub = this.track.addSubscriber(this, EventTypeSubscriptions);
}
setBeat(track: Track): void {
this.sub.unbind();
this.track = track;
this.setupBindings();
EventTypeSubscriptions.forEach(eventType => this.notify(null, eventType));
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch(event) {
case TrackEvents.NewName:
this.title.setText(this.track.getName());
break;
case TrackEvents.LoopLengthChanged:
this.loopLengthInput.setValue(this.track.getLoopLength());
break;
case TrackEvents.DisplayTypeChanged:
this.loopCheckbox.setValue(this.track.isLooping());
this.bakeButton.setDisabled(!this.track.isLooping());
if (this.track.isLooping()) {
this.loopLengthSection.classList.remove("hide");
} else {
this.loopLengthSection.classList.add("hide");
}
break;
}
}
build(): HTMLElement {
this.title = new EditableTextFieldView({
initialText: this.track.getName(),
setter: (newText) => this.track.setName(newText),
});
this.bakeButton = new ActionButtonView({
icon: "snowflake",
type: "secondary",
alt: "Bake Loops",
disabled: !this.track.isLooping(),
onClick: () => this.track.bakeLoops(),
});
this.loopLengthInput = new NumberInputView({
initialValue: this.track.getLoopLength(),
onDecrement: () => this.track.setLoopLength(this.track.getLoopLength() - 1),
onIncrement: () => this.track.setLoopLength(this.track.getLoopLength() + 1),
onNewInput: (input: number) => this.track.setLoopLength(input),
});
this.loopCheckbox = new BoolBoxView({
label: "Loop:",
value: this.track.isLooping(),
onInput: (isChecked: boolean) => this.track.setLooping(isChecked),
});
this.loopLengthSection = h("div", {
classes: ["loop-settings-option"],
}, [
this.loopLengthInput,
]);
if (this.track.isLooping()) {
this.loopLengthSection.classList.remove("hide");
} else {
this.loopLengthSection.classList.add("hide");
}
return h("div", {
classes: ["track-settings"],
}, [
h("div", {
classes: ["track-settings-title-container"]
}, [
this.title,
]),
h("div", {
classes: ["track-settings-lower"],
}, [
this.bakeButton,
new ActionButtonView({
icon: "trash",
type: "secondary",
alt: "Delete Track",
onClick: () => this.track.delete(),
}),
h("div", {
classes: ["loop-settings"],
}, [
this.loopCheckbox,
]),
this.loopLengthSection,
]),
]);
}
}

View File

@@ -1,4 +1,4 @@
.beat-unit {
.track-unit {
width: 2em;
height: 2em;
margin-right: 4px;
@@ -11,45 +11,45 @@
cursor: pointer;
}
.beat-unit:hover {
.track-unit:hover {
border-color: #5f5f5f;
background-color: #5f5f5f;
transition: none;
}
.vertical-mode .beat-unit {
.vertical-mode .track-unit {
margin-bottom: 4px;
display: block;
}
.beat-unit.beat-unit-on {
.track-unit.track-unit-on {
border-color: var(--color-ui-accent);
background-color: var(--color-ui-accent);
transition: none;
}
.beat-unit.beat-unit-on:hover {
.track-unit.track-unit-on:hover {
border-color: var(--color-ui-accent-hover);
background-color: var(--color-ui-accent-hover);
}
.beat-unit.beat-unit-on.beat-unit-accent {
.track-unit.track-unit-on.track-unit-accent {
border-color: var(--color-ui-neutral-light);
background-color: var(--color-ui-accent);
}
.beat-unit.beat-unit-on.beat-unit-accent:hover {
.track-unit.track-unit-on.track-unit-accent:hover {
border-color: var(--color-ui-neutral-light);
background-color: var(--color-ui-accent-hover);
}
.beat-unit.beat-unit-on.beat-unit-ghost {
.track-unit.track-unit-on.track-unit-ghost {
border-color: var(--color-ui-accent);
background-color: var(--color-ui-accent);
opacity: 60%;
}
.beat-unit.beat-unit-on.beat-unit-ghost:hover {
.track-unit.track-unit-on.track-unit-ghost:hover {
border-color: var(--color-ui-accent-hover);
background-color: var(--color-ui-accent-hover);
}

View File

@@ -1,40 +1,40 @@
import BeatUnit, {BeatUnitEvent, BeatUnitType} from "@/BeatUnit";
import TrackUnit, {TrackUnitEvent, TrackUnitType} from "@/TrackUnit";
import ISubscriber from "@/Subscriber";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import {IPublisher, ISubscription, Publisher} from "@/Publisher";
import "./BeatUnit.css";
import "./TrackUnit.css";
export type BeatUnitUINodeOptions = UINodeOptions & {
beatUnit: BeatUnit,
export type TrackUnitUINodeOptions = UINodeOptions & {
trackUnit: TrackUnit,
};
const EventTypeSubscriptions = [
BeatUnitEvent.On,
BeatUnitEvent.Off,
BeatUnitEvent.TypeChange,
TrackUnitEvent.On,
TrackUnitEvent.Off,
TrackUnitEvent.TypeChange,
];
type EventTypeSubscriptions = FlatArray<typeof EventTypeSubscriptions, 1>;
export default class BeatUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private beatUnit: BeatUnit;
export default class TrackUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> {
private trackUnit: TrackUnit;
private subscription: ISubscription | null = null;
private publisher: IPublisher<BeatUnitEvent> = new Publisher<BeatUnitEvent, BeatUnitView>(this);
private publisher: IPublisher<TrackUnitEvent> = new Publisher<TrackUnitEvent, TrackUnitView>(this);
private touchTimeout: ReturnType<typeof setTimeout> | null = null;
private mouseDownListeners: ((ev: MouseEvent) => void)[] = [];
private hoverListeners: ((ev: MouseEvent) => void)[] = [];
constructor(options: BeatUnitUINodeOptions) {
constructor(options: TrackUnitUINodeOptions) {
super(options);
this.beatUnit = options.beatUnit;
this.trackUnit = options.trackUnit;
this.setupBindings();
}
setUnit(beatUnit: BeatUnit | null): void {
if (beatUnit) {
this.beatUnit = beatUnit;
setUnit(trackUnit: TrackUnit | null): void {
if (trackUnit) {
this.trackUnit = trackUnit;
this.setupBindings();
this.notify(this.publisher, beatUnit.isOn() ? BeatUnitEvent.On : BeatUnitEvent.Off);
this.notify(this.publisher, BeatUnitEvent.TypeChange);
this.notify(this.publisher, trackUnit.isOn() ? TrackUnitEvent.On : TrackUnitEvent.Off);
this.notify(this.publisher, TrackUnitEvent.TypeChange);
} else {
this.subscription?.unbind();
}
@@ -42,7 +42,7 @@ export default class BeatUnitView extends UINode implements ISubscriber<EventTyp
private setupBindings() {
this.subscription?.unbind();
this.subscription = this.beatUnit.addSubscriber(this, EventTypeSubscriptions);
this.subscription = this.trackUnit.addSubscriber(this, EventTypeSubscriptions);
this.mouseDownListeners.forEach(listener => this.getNode().removeEventListener("mousedown", listener));
this.hoverListeners.forEach(listener => this.getNode().removeEventListener("mouseover", listener));
this.redraw();
@@ -55,13 +55,13 @@ export default class BeatUnitView extends UINode implements ISubscriber<EventTyp
private handleMouseDown(ev: MouseEvent): void {
if (ev.button === 1) {
this.beatUnit.rotateType();
this.trackUnit.rotateType();
}
}
private handleTouchStart(ev: TouchEvent): void {
this.touchTimeout = this.touchTimeout || setTimeout(() => {
this.beatUnit.rotateType();
this.trackUnit.rotateType();
this.touchTimeout = null;
}, 400);
}
@@ -74,38 +74,38 @@ export default class BeatUnitView extends UINode implements ISubscriber<EventTyp
}
toggle(): void {
this.beatUnit.toggle();
this.trackUnit.toggle();
}
turnOn(): void {
this.beatUnit.setOn(true);
this.trackUnit.setOn(true);
}
turnOff(): void {
this.beatUnit.setOn(false);
this.trackUnit.setOn(false);
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case BeatUnitEvent.On:
this.getNode().classList.add("beat-unit-on");
case TrackUnitEvent.On:
this.getNode().classList.add("track-unit-on");
break;
case BeatUnitEvent.Off:
this.getNode().classList.remove("beat-unit-on");
case TrackUnitEvent.Off:
this.getNode().classList.remove("track-unit-on");
break;
case BeatUnitEvent.TypeChange:
switch (this.beatUnit.getType()) {
case BeatUnitType.Normal:
this.getNode().classList.remove("beat-unit-ghost");
this.getNode().classList.remove("beat-unit-accent");
case TrackUnitEvent.TypeChange:
switch (this.trackUnit.getType()) {
case TrackUnitType.Normal:
this.getNode().classList.remove("track-unit-ghost");
this.getNode().classList.remove("track-unit-accent");
break;
case BeatUnitType.GhostNote:
this.getNode().classList.remove("beat-unit-accent");
this.getNode().classList.add("beat-unit-ghost");
case TrackUnitType.GhostNote:
this.getNode().classList.remove("track-unit-accent");
this.getNode().classList.add("track-unit-ghost");
break;
case BeatUnitType.Accent:
this.getNode().classList.remove("beat-unit-ghost");
this.getNode().classList.add("beat-unit-accent");
case TrackUnitType.Accent:
this.getNode().classList.remove("track-unit-ghost");
this.getNode().classList.add("track-unit-accent");
break;
}
break;
@@ -113,9 +113,9 @@ export default class BeatUnitView extends UINode implements ISubscriber<EventTyp
}
build(): HTMLElement {
const classes = ["beat-unit"];
if (this.beatUnit.isOn()) {
classes.push("beat-unit-on");
const classes = ["track-unit"];
if (this.trackUnit.isOn()) {
classes.push("track-unit-on");
}
return h("div", {
classes: classes,

View File

@@ -49,45 +49,6 @@ export default abstract class UINode {
protected abstract build(): HTMLElement;
}
export function h<
T extends keyof HTMLElementTagNameMap>(
type: T,
attributes: IRenderAttributes<T>,
subNodes?: (Node | UINode | Ref)[],
): HTMLElementTagNameMap[T] {
const element = document.createElement(type);
if (attributes) {
for (const key in attributes) {
if (!Object.prototype.hasOwnProperty.call(attributes, key)) {
continue;
}
if (key === "classes" && attributes.classes) {
element.classList.add(...attributes.classes);
} else if (key === "saveTo" && attributes.saveTo) {
attributes.saveTo.val = element;
} else if (Object.prototype.hasOwnProperty.call(attributes, key)) {
const attribute = (attributes as any)[key];
if (attribute) {
if (attribute instanceof Ref) {
(element as any)[key] = attribute.val;
attribute.watch((newVal) => (element as any)[key] = newVal);
} else {
(element as any)[key] = attribute;
}
}
}
}
}
if (subNodes) {
attachSubs(element, subNodes);
}
return element;
}
export function q(text: string): Text {
return document.createTextNode(text);
}
export function frag(subs?: Node[]): DocumentFragment {
const frag = document.createDocumentFragment();
if (subs) {
@@ -96,6 +57,27 @@ export function frag(subs?: Node[]): DocumentFragment {
return frag;
}
export function q(text: string): Text {
return document.createTextNode(text);
}
export function h<T extends keyof HTMLElementTagNameMap>(type: T, attributes?: IRenderAttributes<T>, subNodes?: (Node | UINode | Ref)[]): HTMLElementTagNameMap[T] {
const element = document.createElement(type);
if (attributes) {
if (attributes.classes) {
element.classList.add(...attributes.classes);
}
if (attributes.saveTo) {
attributes.saveTo.val = element;
}
applyAttributes(element, attributes);
}
if (subNodes) {
attachSubs(element, subNodes);
}
return element;
}
function nodeRefWatcher<T>(newVal: T extends Ref<infer U> ? U : never, textNode: Text, sub: ISubscription): void {
if (!textNode.parentNode) {
sub.unbind();
@@ -119,3 +101,21 @@ function attachSubs(node: Element | DocumentFragment, subNodes: (Node | UINode |
}
}
}
function applyAttributes<T extends keyof HTMLElementTagNameMap>(element: HTMLElement, attributes: IRenderAttributes<T>): void {
for (const key in attributes) {
if (Object.prototype.hasOwnProperty.call(attributes, key)) {
const attribute = (attributes as Record<string, unknown>)[key];
if (attribute) {
if (attribute instanceof Ref) {
const attributeAsRef = attribute as Ref;
const elementWithAttributeKey = element as unknown as Record<string, typeof attributeAsRef.val>;
elementWithAttributeKey[key] = attributeAsRef.val;
attribute.watch((newVal) => elementWithAttributeKey[key] = newVal);
} else {
(element as unknown as ({ [key: string]: typeof attribute }))[key] = attribute;
}
}
}
}
}

View File

View File

@@ -0,0 +1,52 @@
import "./Dropdown.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import Ref, {MaybeRef} from "@/Ref";
export type DropdownViewOption = {
label: string,
value: string,
};
export type DropdownUINodeOptions = UINodeOptions & {
options: MaybeRef<DropdownViewOption[]>,
};
export default class DropdownView extends UINode {
private options: Ref<DropdownViewOption[]>;
private select = Ref.new<HTMLSelectElement | null>(null);
constructor(options: DropdownUINodeOptions) {
super(options);
this.options = Ref.new(options.options);
this.options.watch((newVal) => this.updateOptionsFrom(newVal));
}
private updateOptionsFrom(newOptions: DropdownViewOption[]): void {
const select = this.select.val;
if (!select) {
return;
}
const children = new Array(...select.children) as HTMLOptionElement[];
for (let i = 0; i < newOptions.length; i++) {
if (children[i]) {
children[i].label = newOptions[i].label;
children[i].value = newOptions[i].value;
} else {
children.push(h("option", {
label: newOptions[i].label,
value: newOptions[i].value,
}));
}
}
if (children.length - newOptions.length > 0) {
children.splice(newOptions.length, children.length - newOptions.length).forEach(child => child.remove());
}
select.replaceChildren(...children);
}
protected build(): HTMLSelectElement {
return h("select", {
saveTo: this.select,
}, this.options.val.map(opt => h("option", {label: opt.label})));
}
}

View File

@@ -52,10 +52,12 @@ input[type="number"].number-input-input::-webkit-outer-spin-button {
}
.number-input-inc {
width: 1.4em;
border-radius: 0 0.5em 0.5em 0;
}
.number-input-dec {
width: 1.4em;
border-radius: 0.5em 0 0 0.5em;
}

View File

@@ -27,8 +27,8 @@ type NumberInputUINodeOptionsGetSet = NumberInputUINodeOptionsBase & {
export type NumberInputUINodeOptions = NumberInputUINodeOptionsGetSet | NumberInputUINodeOptionsIncDecInput;
export default class NumberInputView extends UINode {
private labelElement: Ref<HTMLLabelElement | null> = new Ref<HTMLLabelElement | null>(null);
private inputElement: Ref<HTMLInputElement | null> = new Ref<HTMLInputElement | null>(null);
private labelElement: Ref<HTMLLabelElement | null> = Ref.new<HTMLLabelElement | null>(null);
private inputElement: Ref<HTMLInputElement | null> = Ref.new<HTMLInputElement | null>(null);
private labelPosition: "top" | "left";
private value: number;
private label: string | null;