From 5b8e1606081d71409720509c587a1554aabaa174 Mon Sep 17 00:00:00 2001 From: Daniel Ledda Date: Sun, 3 Apr 2022 14:29:56 +0200 Subject: [PATCH] refactor: loads of sensible renaming --- src/Beat.ts | 474 +++++++++++------- src/BeatGroup.ts | 306 ----------- src/BeatLike.ts | 11 - src/BeatUnit.ts | 78 --- src/Ref.ts | 16 +- src/Store.ts | 10 + src/Track.ts | 168 +++++++ src/TrackUnit.ts | 78 +++ src/tests.ts | 4 +- src/ui/Beat/Beat.css | 72 +-- src/ui/Beat/BeatView.ts | 196 ++------ src/ui/BeatGroup/BeatGroup.css | 15 - src/ui/BeatGroup/BeatGroupView.ts | 87 ---- .../BeatGroupSettings/BeatGroupSettings.css | 20 - .../BeatGroupSettingsView.ts | 140 ------ src/ui/BeatSettings/BeatSettings.css | 56 +-- src/ui/BeatSettings/BeatSettingsView.ts | 176 ++++--- src/ui/Root/RootView.ts | 56 +-- src/ui/StageTitleBar/StageTitleBar.css | 5 - src/ui/StageTitleBar/StageTitleBarView.ts | 34 +- src/ui/Track/Track.css | 65 +++ src/ui/Track/TrackView.ts | 177 +++++++ src/ui/TrackSettings/TrackSettings.css | 54 ++ src/ui/TrackSettings/TrackSettingsView.ts | 130 +++++ .../BeatUnit.css => TrackUnit/TrackUnit.css} | 18 +- .../TrackUnitView.ts} | 82 +-- src/ui/UINode.ts | 80 +-- src/ui/Widgets/Dropdown/Dropdown.css | 0 src/ui/Widgets/Dropdown/DropdownView.ts | 52 ++ src/ui/Widgets/NumberInput/NumberInput.css | 2 + src/ui/Widgets/NumberInput/NumberInputView.ts | 4 +- 31 files changed, 1364 insertions(+), 1302 deletions(-) delete mode 100644 src/BeatGroup.ts delete mode 100644 src/BeatLike.ts delete mode 100644 src/BeatUnit.ts create mode 100644 src/Store.ts create mode 100644 src/Track.ts create mode 100644 src/TrackUnit.ts delete mode 100644 src/ui/BeatGroup/BeatGroup.css delete mode 100644 src/ui/BeatGroup/BeatGroupView.ts delete mode 100644 src/ui/BeatGroupSettings/BeatGroupSettings.css delete mode 100644 src/ui/BeatGroupSettings/BeatGroupSettingsView.ts create mode 100644 src/ui/Track/Track.css create mode 100644 src/ui/Track/TrackView.ts create mode 100644 src/ui/TrackSettings/TrackSettings.css create mode 100644 src/ui/TrackSettings/TrackSettingsView.ts rename src/ui/{BeatUnit/BeatUnit.css => TrackUnit/TrackUnit.css} (76%) rename src/ui/{BeatUnit/BeatUnitView.ts => TrackUnit/TrackUnitView.ts} (56%) create mode 100644 src/ui/Widgets/Dropdown/Dropdown.css create mode 100644 src/ui/Widgets/Dropdown/DropdownView.ts diff --git a/src/Beat.ts b/src/Beat.ts index 6bc8677..97ea38b 100644 --- a/src/Beat.ts +++ b/src/Beat.ts @@ -1,169 +1,305 @@ -import BeatUnit from "@/BeatUnit"; -import {IPublisher, Publisher} from "@/Publisher"; -import ISubscriber from "@/Subscriber"; -import BeatLike from "@/BeatLike"; -import {isPosInt} from "@/utils"; - -export type BeatInitOptions = { - timeSig?: { - up: number, - down: number, - }, - name?: string, - bars?: number, - isLooping?: boolean, - loopLength?: number, -}; - -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", -} - -export default class Beat implements IPublisher, BeatLike { - private static count = 0; - private readonly key: string; - private name: string; - private timeSigUp = 4; - private timeSigDown = 4; - private readonly unitRecord: BeatUnit[] = []; - private barCount = 1; - private publisher = new Publisher(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; - } - - setLoopLength(loopLength: number): void { - if (!isPosInt(loopLength) || loopLength < 2) { - loopLength = this.loopLength; - } - this.loopLength = loopLength; - this.publisher.notifySubs(BeatEvents.LoopLengthChanged); - } - - setLooping(isLooping: boolean): void { - this.looping = isLooping; - this.publisher.notifySubs(BeatEvents.DisplayTypeChanged); - } - - addSubscriber(subscriber: ISubscriber, 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) { - barCount = this.barCount; - } - this.barCount = barCount; - this.updateBeatUnitLength(); - this.publisher.notifySubs(BeatEvents.NewBarCount); - } - - getUnitByIndex(index: number): BeatUnit | null { - if (this.looping) { - index %= this.loopLength; - } - 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; - } - - static isValidTimeSigRange(sig: number): boolean { - return sig >= 2 && sig <= 32; - } - - setName(newName: string): void { - this.name = newName; - this.publisher.notifySubs(BeatEvents.NewName); - } - - 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); - } - } -} \ No newline at end of file +import Track, {TrackEvents, TrackInitOptions} from "@/Track"; +import {IPublisher, Publisher} from "@/Publisher"; +import ISubscriber from "@/Subscriber"; +import {greatestCommonDivisor, isPosInt} from "@/utils"; + +type BeatGroupInitOptions = { + barCount: number; + isLooping: boolean; + timeSigUp: number; + tracks?: TrackInitOptions[], + loopLength?: number, + useAutoBeatLength?: boolean, + name?: string, +}; + +export const enum BeatEvents { + 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", +} + +type EventTypeSubscriptions = + | TrackEvents.LoopLengthChanged + | TrackEvents.DisplayTypeChanged + | TrackEvents.WantsRemoval + | TrackEvents.Baked; + +export default class Beat implements IPublisher, ISubscriber { + private static globalCounter = 0; + private tracks: Track[] = []; + private publisher: Publisher = new Publisher(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) { + 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; + } + + 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; + } + } + + addSubscriber(subscriber: ISubscriber, eventType: BeatEvents | BeatEvents[]): { unbind: () => void } { + return this.publisher.addSubscriber(subscriber, eventType); + } + + private setBarCountInternal(barCount: number): void { + if (!isPosInt(barCount)) { + barCount = this.barCount; + } + this.barCount = barCount; + for (const track of this.tracks) { + track.setBarCount(barCount); + } + this.publisher.notifySubs(BeatEvents.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 track of this.tracks) { + track.setLoopLength(loopLength); + } + this.publisher.notifySubs(BeatEvents.GlobalLoopLengthChanged); + } + + 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.NameChanged); + } + + getName(): string { + return this.name; + } +} diff --git a/src/BeatGroup.ts b/src/BeatGroup.ts deleted file mode 100644 index e7402df..0000000 --- a/src/BeatGroup.ts +++ /dev/null @@ -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, ISubscriber { - private static globalCounter = 0; - private beats: Beat[] = []; - private publisher: Publisher = new Publisher(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, 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; - } -} diff --git a/src/BeatLike.ts b/src/BeatLike.ts deleted file mode 100644 index b32fa86..0000000 --- a/src/BeatLike.ts +++ /dev/null @@ -1,11 +0,0 @@ -import {IPublisher} from "./Publisher"; -import {BeatEvents} from "./Beat"; - -export default interface BeatLike extends IPublisher{ - setBarCount(barCount: number): void; - getBarCount(): void; - setLooping(isLooping: boolean): void; - isLooping(): boolean; - setLoopLength(loopLength: number): void; - getLoopLength(): number; -} diff --git a/src/BeatUnit.ts b/src/BeatUnit.ts deleted file mode 100644 index a8ac87c..0000000 --- a/src/BeatUnit.ts +++ /dev/null @@ -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 { - private static readonly TypeRotation = [ - BeatUnitType.Normal, - BeatUnitType.GhostNote, - BeatUnitType.Accent, - ] as const; - private publisher: Publisher = new Publisher(this); - private on = false; - private typeIndex = 0; - - constructor(on = false, type = BeatUnitType.Normal) { - this.on = on; - this.setType(type); - } - - addSubscriber(subscriber: ISubscriber, 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()); - } -} diff --git a/src/Ref.ts b/src/Ref.ts index 3c91d58..42af00a 100644 --- a/src/Ref.ts +++ b/src/Ref.ts @@ -1,5 +1,7 @@ import {ISubscription} from "@/Publisher"; +export type MaybeRef = T | Ref; + class RefSubscription implements ISubscription { private unbindCallback?: () => void; @@ -16,17 +18,27 @@ interface Stringable { toString(): string; } -export default class Ref { +type AllowedRef = { toString(): string } | string | null; + +export default class Ref { 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(val: MaybeRef): Ref { + if (val instanceof Ref) { + return val; + } else { + return new Ref(val); + } + } + watch(watcher: (newVal: T) => void): ISubscription { if (this.watchers === null) { this.watchers = []; diff --git a/src/Store.ts b/src/Store.ts new file mode 100644 index 0000000..b957eff --- /dev/null +++ b/src/Store.ts @@ -0,0 +1,10 @@ +import Beat from "@/Beat"; + +export default class Store { + private beats: Beat[]; + + constructor() { + this.beats = []; + } + +} \ No newline at end of file diff --git a/src/Track.ts b/src/Track.ts new file mode 100644 index 0000000..17c17b7 --- /dev/null +++ b/src/Track.ts @@ -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 { + 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(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, 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); + } + } +} \ No newline at end of file diff --git a/src/TrackUnit.ts b/src/TrackUnit.ts new file mode 100644 index 0000000..01c336b --- /dev/null +++ b/src/TrackUnit.ts @@ -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 { + private static readonly TypeRotation = [ + TrackUnitType.Normal, + TrackUnitType.GhostNote, + TrackUnitType.Accent, + ] as const; + private publisher: Publisher = new Publisher(this); + private on = false; + private typeIndex = 0; + + constructor(on = false, type = TrackUnitType.Normal) { + this.on = on; + this.setType(type); + } + + addSubscriber(subscriber: ISubscriber, 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()); + } +} diff --git a/src/tests.ts b/src/tests.ts index f8afffd..23b48b3 100644 --- a/src/tests.ts +++ b/src/tests.ts @@ -1,4 +1,4 @@ -import {BeatUnitType} from "./BeatUnit"; -import Beat from "./Beat"; +import {TrackUnitType} from "./TrackUnit"; +import Track from "./Beat"; diff --git a/src/ui/Beat/Beat.css b/src/ui/Beat/Beat.css index f8873e7..5d9f19c 100644 --- a/src/ui/Beat/Beat.css +++ b/src/ui/Beat/Beat.css @@ -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; -} \ No newline at end of file + height: inherit; + overflow-x: hidden; + overflow-y: scroll; + display: block; +} diff --git a/src/ui/Beat/BeatView.ts b/src/ui/Beat/BeatView.ts index 741888a..e6a2894 100644 --- a/src/ui/Beat/BeatView.ts +++ b/src/ui/Beat/BeatView.ts @@ -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; export default class BeatView extends UINode implements ISubscriber { - private beat!: Beat; - private title = new Ref(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); - } else { - view = new BeatUnitView({beatUnit}); - this.beatUnitViews.push(view); - view.onHover(() => this.onBeatViewHover(view)); - view.onMouseDown((event: MouseEvent) => this.onBeatUnitClick(event.button, 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); - } - } - - private onBeatViewHover(beatView: BeatUnitView) { - this.lastHoveredBeatUnitView = beatView; - if (BeatView.selectingUnits) { - this.lastHoveredBeatUnitView.turnOn(); - } else if (BeatView.deselectingUnits) { - this.lastHoveredBeatUnitView.turnOff(); - } - } - - 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 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); + 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 { - break; - } - i++; - bars++; - if (bars === barCount) { - spacersInserted = true; + this.trackViews.push(new TrackView({track: this.beat.getTrackByIndex(i)})); } } + const deadTrackViews = this.trackViews.splice(newCount, this.trackViews.length - newCount); + deadTrackViews.forEach(beatView => beatView.setBeat(null)); + if (this.currentOrientation === "horizontal") { + this.reverseDisplayOrder(); + } } - private setupBeatUnits(): void { - this.rebuildBeatUnitViews(); - this.buildBeatUnitViewBlock(); - this.respaceBeatUnits(); + setOrientation(orientation: "vertical" | "horizontal"): void { + if (this.currentOrientation !== orientation) { + this.reverseDisplayOrder(); + this.currentOrientation = orientation; + } } - build(): HTMLElement { - this.setupBeatUnits(); - if (!this.beatUnitViewBlock) { - throw new Error("Beat unit block setup failed!"); - } + private reverseDisplayOrder(): void { + this.trackViews.reverse(); + this.getNode().classList.toggle("vertical"); + this.redraw(); + } + + setBeatGroup(newBeatGroup: Beat): void { + this.beat = newBeatGroup; + this.subscription.unbind(); + this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged); + this.setupBeatViews(); + this.redraw(); + } + + 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; -}); \ No newline at end of file +} \ No newline at end of file diff --git a/src/ui/BeatGroup/BeatGroup.css b/src/ui/BeatGroup/BeatGroup.css deleted file mode 100644 index 18cb3c1..0000000 --- a/src/ui/BeatGroup/BeatGroup.css +++ /dev/null @@ -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; -} diff --git a/src/ui/BeatGroup/BeatGroupView.ts b/src/ui/BeatGroup/BeatGroupView.ts deleted file mode 100644 index b58641b..0000000 --- a/src/ui/BeatGroup/BeatGroupView.ts +++ /dev/null @@ -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; - -export default class BeatGroupView extends UINode implements ISubscriber { - 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 - ]); - } -} \ No newline at end of file diff --git a/src/ui/BeatGroupSettings/BeatGroupSettings.css b/src/ui/BeatGroupSettings/BeatGroupSettings.css deleted file mode 100644 index 358ce2d..0000000 --- a/src/ui/BeatGroupSettings/BeatGroupSettings.css +++ /dev/null @@ -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; -} diff --git a/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts b/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts deleted file mode 100644 index da55d6f..0000000 --- a/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts +++ /dev/null @@ -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; - -export default class BeatGroupSettingsView extends UINode implements ISubscriber { - 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, - ]), - ]); - } -} \ No newline at end of file diff --git a/src/ui/BeatSettings/BeatSettings.css b/src/ui/BeatSettings/BeatSettings.css index 9544bc7..c535b8b 100644 --- a/src/ui/BeatSettings/BeatSettings.css +++ b/src/ui/BeatSettings/BeatSettings.css @@ -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; -} \ No newline at end of file +.beat-settings-option-group.visible { + display: inline-block; +} diff --git a/src/ui/BeatSettings/BeatSettingsView.ts b/src/ui/BeatSettings/BeatSettingsView.ts index 056e104..c44041d 100644 --- a/src/ui/BeatSettings/BeatSettingsView.ts +++ b/src/ui/BeatSettings/BeatSettingsView.ts @@ -1,130 +1,140 @@ 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; export default class BeatSettingsView extends UINode implements ISubscriber { 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"); - } else { - this.loopLengthSection.classList.add("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.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-lower"], - }, [ - this.bakeButton, - new ActionButtonView({ - icon: "trash", - type: "secondary", - alt: "Delete Track", - onClick: () => this.beat.delete(), - }), h("div", { - classes: ["loop-settings"], + classes: ["beat-settings-boxes", "beat-settings-option"], }, [ - this.loopCheckbox, + this.timeSigUpInput, ]), - this.loopLengthSection, + h("div", { + classes: ["beat-settings-bar-count", "beat-settings-option"] + , + }, [ + this.barCountInput, + ]), + h("div", { + classes: ["beat-settings-bar-count", "beat-settings-option"], + }, [ + this.autoBeatLengthCheckbox, + ]), + new ActionButtonView({ + label: "New Track", + onClick: () => this.beat.addTrack(), + }), + this.trackSettingsContainer, ]), ]); } -} +} \ No newline at end of file diff --git a/src/ui/Root/RootView.ts b/src/ui/Root/RootView.ts index f6e4677..92d1ee1 100644 --- a/src/ui/Root/RootView.ts +++ b/src/ui/Root/RootView.ts @@ -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 = new Ref(null); + private showHideSidebarButton: Ref = Ref.new(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, ]) ]) ]) diff --git a/src/ui/StageTitleBar/StageTitleBar.css b/src/ui/StageTitleBar/StageTitleBar.css index 113231c..71ce3d3 100644 --- a/src/ui/StageTitleBar/StageTitleBar.css +++ b/src/ui/StageTitleBar/StageTitleBar.css @@ -11,11 +11,6 @@ align-items: center; } -.stage-title-bar-preamble { - margin-bottom: 4px; - font-size: 12px; -} - .stage-title-bar * { flex: 1; } diff --git a/src/ui/StageTitleBar/StageTitleBarView.ts b/src/ui/StageTitleBar/StageTitleBarView.ts index d8e59ea..f4ed55a 100644 --- a/src/ui/StageTitleBar/StageTitleBarView.ts +++ b/src/ui/StageTitleBar/StageTitleBarView.ts @@ -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; export default class StageTitleBarView extends UINode implements ISubscriber { private sub: ISubscription; - private beatGroup: BeatGroup; + private beat: Beat; private title: EditableTextFieldView; + private options: Ref; 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([]); } 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}) ]); } } diff --git a/src/ui/Track/Track.css b/src/ui/Track/Track.css new file mode 100644 index 0000000..ff7eba4 --- /dev/null +++ b/src/ui/Track/Track.css @@ -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; +} \ No newline at end of file diff --git a/src/ui/Track/TrackView.ts b/src/ui/Track/TrackView.ts new file mode 100644 index 0000000..197f248 --- /dev/null +++ b/src/ui/Track/TrackView.ts @@ -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; + +export default class TrackView extends UINode implements ISubscriber { + private track!: Track; + private title = Ref.new(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; +}); \ No newline at end of file diff --git a/src/ui/TrackSettings/TrackSettings.css b/src/ui/TrackSettings/TrackSettings.css new file mode 100644 index 0000000..7e34695 --- /dev/null +++ b/src/ui/TrackSettings/TrackSettings.css @@ -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; +} \ No newline at end of file diff --git a/src/ui/TrackSettings/TrackSettingsView.ts b/src/ui/TrackSettings/TrackSettingsView.ts new file mode 100644 index 0000000..a79cc7f --- /dev/null +++ b/src/ui/TrackSettings/TrackSettingsView.ts @@ -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; + +export default class TrackSettingsView extends UINode implements ISubscriber { + 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, + ]), + ]); + } +} diff --git a/src/ui/BeatUnit/BeatUnit.css b/src/ui/TrackUnit/TrackUnit.css similarity index 76% rename from src/ui/BeatUnit/BeatUnit.css rename to src/ui/TrackUnit/TrackUnit.css index 585e417..e1126a0 100644 --- a/src/ui/BeatUnit/BeatUnit.css +++ b/src/ui/TrackUnit/TrackUnit.css @@ -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); } diff --git a/src/ui/BeatUnit/BeatUnitView.ts b/src/ui/TrackUnit/TrackUnitView.ts similarity index 56% rename from src/ui/BeatUnit/BeatUnitView.ts rename to src/ui/TrackUnit/TrackUnitView.ts index 21f03bf..81d7572 100644 --- a/src/ui/BeatUnit/BeatUnitView.ts +++ b/src/ui/TrackUnit/TrackUnitView.ts @@ -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; -export default class BeatUnitView extends UINode implements ISubscriber { - private beatUnit: BeatUnit; +export default class TrackUnitView extends UINode implements ISubscriber { + private trackUnit: TrackUnit; private subscription: ISubscription | null = null; - private publisher: IPublisher = new Publisher(this); + private publisher: IPublisher = new Publisher(this); private touchTimeout: ReturnType | 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 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 { - this.beatUnit.rotateType(); + this.trackUnit.rotateType(); this.touchTimeout = null; }, 400); } @@ -74,38 +74,38 @@ export default class BeatUnitView extends UINode implements ISubscriber( - type: T, - attributes: IRenderAttributes, - 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(type: T, attributes?: IRenderAttributes, 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(newVal: T extends Ref ? U : never, textNode: Text, sub: ISubscription): void { if (!textNode.parentNode) { sub.unbind(); @@ -118,4 +100,22 @@ function attachSubs(node: Element | DocumentFragment, subNodes: (Node | UINode | node.append(subNode); } } -} \ No newline at end of file +} + +function applyAttributes(element: HTMLElement, attributes: IRenderAttributes): void { + for (const key in attributes) { + if (Object.prototype.hasOwnProperty.call(attributes, key)) { + const attribute = (attributes as Record)[key]; + if (attribute) { + if (attribute instanceof Ref) { + const attributeAsRef = attribute as Ref; + const elementWithAttributeKey = element as unknown as Record; + elementWithAttributeKey[key] = attributeAsRef.val; + attribute.watch((newVal) => elementWithAttributeKey[key] = newVal); + } else { + (element as unknown as ({ [key: string]: typeof attribute }))[key] = attribute; + } + } + } + } +} diff --git a/src/ui/Widgets/Dropdown/Dropdown.css b/src/ui/Widgets/Dropdown/Dropdown.css new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/Widgets/Dropdown/DropdownView.ts b/src/ui/Widgets/Dropdown/DropdownView.ts new file mode 100644 index 0000000..db147f2 --- /dev/null +++ b/src/ui/Widgets/Dropdown/DropdownView.ts @@ -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, +}; + +export default class DropdownView extends UINode { + private options: Ref; + private select = Ref.new(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}))); + } +} diff --git a/src/ui/Widgets/NumberInput/NumberInput.css b/src/ui/Widgets/NumberInput/NumberInput.css index ec172e9..d987653 100644 --- a/src/ui/Widgets/NumberInput/NumberInput.css +++ b/src/ui/Widgets/NumberInput/NumberInput.css @@ -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; } diff --git a/src/ui/Widgets/NumberInput/NumberInputView.ts b/src/ui/Widgets/NumberInput/NumberInputView.ts index e534dfe..12b0262 100644 --- a/src/ui/Widgets/NumberInput/NumberInputView.ts +++ b/src/ui/Widgets/NumberInput/NumberInputView.ts @@ -27,8 +27,8 @@ type NumberInputUINodeOptionsGetSet = NumberInputUINodeOptionsBase & { export type NumberInputUINodeOptions = NumberInputUINodeOptionsGetSet | NumberInputUINodeOptionsIncDecInput; export default class NumberInputView extends UINode { - private labelElement: Ref = new Ref(null); - private inputElement: Ref = new Ref(null); + private labelElement: Ref = Ref.new(null); + private inputElement: Ref = Ref.new(null); private labelPosition: "top" | "left"; private value: number; private label: string | null;