diff --git a/src/Beat.ts b/src/Beat.ts index d3823ab..0e1d453 100644 --- a/src/Beat.ts +++ b/src/Beat.ts @@ -21,6 +21,7 @@ export const enum BeatEvents { NewName="BE2", DisplayTypeChanged="BE3", LoopLengthChanged="BE4", + WantsRemoval="BE5", } export default class Beat implements IPublisher, BeatLike { @@ -31,7 +32,7 @@ export default class Beat implements IPublisher, BeatLike { private timeSigDown = 4; private readonly unitRecord: BeatUnit[] = []; private barCount = 1; - private publisher = new Publisher(); + private publisher = new Publisher(this); private loopLength: number; private looping: boolean; @@ -145,4 +146,8 @@ export default class Beat implements IPublisher, BeatLike { getLoopLength(): number { return this.loopLength; } + + delete(): void { + this.publisher.notifySubs(BeatEvents.WantsRemoval); + } } \ No newline at end of file diff --git a/src/BeatGroup.ts b/src/BeatGroup.ts index 79bfff0..467f091 100644 --- a/src/BeatGroup.ts +++ b/src/BeatGroup.ts @@ -2,7 +2,7 @@ import Beat, {BeatEvents, BeatInitOptions} from "./Beat"; import {IPublisher, Publisher} from "./Publisher"; import ISubscriber from "./Subscriber"; import BeatLike from "./BeatLike"; -import {isPosInt} from "./utils"; +import {greatestCommonDivisor, isPosInt} from "./utils"; type BeatGroupInitOptions = { barCount: number; @@ -10,7 +10,6 @@ type BeatGroupInitOptions = { timeSigUp: number; beats?: BeatInitOptions[], loopLength?: number, - forceFullBars?: boolean, useAutoBeatLength?: boolean, }; @@ -20,38 +19,37 @@ export const enum BeatGroupEvents { BarCountChanged="BGE2", TimeSigUpChanged="BGE3", AutoBeatSettingsChanged="BGE4", + LockingChanged="BGE5", } export default class BeatGroup implements IPublisher, BeatLike, ISubscriber { private beats: Beat[] = []; - private beatKeyMap: Record = {}; - private publisher: Publisher = new Publisher(); + private publisher: Publisher = new Publisher(this); private barCount: number; private timeSigUp: number; private globalLoopLength: number; private globalIsLooping: boolean; - private forceFullBars: boolean; private useAutoBeatLength: boolean; + private barSettingsLocked = false; constructor(options?: BeatGroupInitOptions) { if (options?.beats) { for (const beatOptions of options.beats) { - const newBeat = new Beat(beatOptions); - this.beats.push(newBeat); - this.beatKeyMap[newBeat.getKey()] = this.beats.length - 1; + this.addBeat(beatOptions); } } this.barCount = options?.barCount ?? 4; this.timeSigUp = options?.timeSigUp ?? 4; - this.globalLoopLength = options?.loopLength ?? this.barCount * this.timeSigUp; + this.globalLoopLength = options?.loopLength ?? this.timeSigUp; this.globalIsLooping = options?.isLooping ?? false; this.useAutoBeatLength = options?.useAutoBeatLength ?? false; - this.forceFullBars = options?.forceFullBars ?? true; } notify(publisher: IPublisher, event: "all" | T[] | T): void { - if (event === BeatEvents.LoopLengthChanged) { + if (event === BeatEvents.LoopLengthChanged || event === BeatEvents.DisplayTypeChanged) { this.autoBeatLength(); + } else if (event === BeatEvents.WantsRemoval) { + this.removeBeat((publisher as Beat).getKey()); } } @@ -59,7 +57,7 @@ export default class BeatGroup implements IPublisher prev * curr, 1); + if (loopLengths.length === 1) { + loopLengths.push(1); + } + return loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr)); } setTimeSigUp(timeSigUp: number): void { @@ -136,6 +131,7 @@ export default class BeatGroup implements IPublisher beat.getKey() === beatKey); + if (typeof foundBeat === "undefined") { throw new Error(`Could not find the beat with key: ${beatKey}`); } - return this.getBeatByIndex(this.beatKeyMap[beatKey]); + return foundBeat; } getBeatByIndex(beatIndex: number): Beat { @@ -170,39 +167,33 @@ export default class BeatGroup implements IPublisher 0) { this.swapBeatsByIndices(index, index - 1); } this.publisher.notifySubs(BeatGroupEvents.BeatOrderChanged); + this.publisher.notifySubs(BeatGroupEvents.BeatListChanged); } moveBeatForward(beatKey: string): void { - const index = this.beatKeyMap[beatKey]; + 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.beatKeyMap[beatKey] > 0; + return this.beats.indexOf(this.getBeatByKey(beatKey)) > 0; } canMoveBeatForward(beatKey: string): boolean { - return this.beatKeyMap[beatKey] < this.beats.length - 1; + return this.beats.indexOf(this.getBeatByKey(beatKey)) < this.beats.length - 1; } addBeat(options?: BeatInitOptions): Beat { @@ -218,16 +209,20 @@ export default class BeatGroup implements IPublisher { - private publisher: Publisher = new Publisher(); + private publisher: Publisher = new Publisher(this); private on = false; private type: BeatUnitType = BeatUnitType.Normal; diff --git a/src/Publisher.ts b/src/Publisher.ts index be84daf..4ecda57 100644 --- a/src/Publisher.ts +++ b/src/Publisher.ts @@ -1,9 +1,11 @@ import ISubscriber from "./Subscriber"; -export class Publisher implements IPublisher { +export class Publisher implements IPublisher { private subscribers: Map; + private parent: P; - constructor() { + constructor(parent: P) { + this.parent = parent; this.subscribers = new Map(); this.subscribers.set("all", []); } @@ -41,10 +43,10 @@ export class Publisher implements IPublisher { notifySubs(eventType: T) { for (const sub of this.getSubscribers(eventType)) { - sub.notify(this, eventType); + sub.notify(this.parent, eventType); } for (const sub of this.getSubscribers("all")) { - sub.notify(this, eventType); + sub.notify(this.parent, eventType); } } } diff --git a/src/Subscriber.ts b/src/Subscriber.ts index 1ccc2f8..2b24242 100644 --- a/src/Subscriber.ts +++ b/src/Subscriber.ts @@ -1,5 +1,3 @@ -import {IPublisher} from "./Publisher"; - export default interface ISubscriber { - notify(publisher: IPublisher, event: T | "all" | T[]): void; + notify(publisher: unknown, event: T | "all" | T[]): void; } \ No newline at end of file diff --git a/src/ui/BeatGroup/Beat/Beat.css b/src/ui/BeatGroup/Beat/Beat.css index 0e74602..a0cc5cd 100644 --- a/src/ui/BeatGroup/Beat/Beat.css +++ b/src/ui/BeatGroup/Beat/Beat.css @@ -8,8 +8,8 @@ } .beat-title { - line-height: 2em; width: 3em; + line-height: 32px; margin: 0; } @@ -29,4 +29,5 @@ .beat { width: max-content; + margin-bottom: 4px; } \ No newline at end of file diff --git a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css index ad5c3a8..151953e 100644 --- a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css +++ b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css @@ -1,7 +1,7 @@ .beat-unit { width: 2em; height: 2em; - margin-right: 0.2em; + margin-right: 4px; background-color: var(--color-ui-neutral-dark); border-width: 0.1em 0.1em 0.1em 0.1em; border-color: var(--color-ui-neutral-dark); diff --git a/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts b/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts index 46a80e8..6094f48 100644 --- a/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts +++ b/src/ui/BeatGroupSettings/BeatGroupSettingsView.ts @@ -17,11 +17,9 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber private beatGroup: BeatGroup; private barCountInput!: NumberInputView; private timeSigUpInput!: NumberInputView; - private loopSettingsView!: BeatLikeLoopSettingsView; private autoBeatLengthCheckbox!: BoolBoxView; private beatSettingsViews: BeatSettingsView[] = []; private beatSettingsContainer!: HTMLDivElement; - private autoBeatOptions!: HTMLElement; constructor(options: BeatGroupSettingsUINodeOptions) { super(options); @@ -31,6 +29,7 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber BeatGroupEvents.TimeSigUpChanged, BeatEvents.DisplayTypeChanged, BeatGroupEvents.BeatListChanged, + BeatGroupEvents.LockingChanged, ]); } @@ -39,14 +38,14 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber this.barCountInput.setValue(this.beatGroup.getBarCount()); } else if (event === BeatGroupEvents.TimeSigUpChanged) { this.timeSigUpInput.setValue(this.beatGroup.getTimeSigUp()); - } else if (event === BeatEvents.DisplayTypeChanged) { - if (this.beatGroup.isLooping()) { - this.autoBeatOptions.classList.add("visible"); - } else { - this.autoBeatOptions.classList.remove("visible"); - } } else if (event === BeatGroupEvents.BeatListChanged) { this.remakeBeatSettingsViews(); + } else if (event === BeatGroupEvents.LockingChanged) { + if (this.beatGroup.barsLocked()) { + this.barCountInput.disable(); + } else { + this.barCountInput.enable(); + } } } @@ -70,7 +69,6 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber } rebuild(): HTMLElement { - this.loopSettingsView = new BeatLikeLoopSettingsView({beatLike: this.beatGroup}); this.barCountInput = new NumberInputView({ label: "Bars:", initialValue: this.beatGroup.getBarCount(), @@ -88,17 +86,6 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber value: this.beatGroup.autoBeatLengthOn(), onInput: (isChecked: boolean) => this.beatGroup.setIsUsingAutoBeatLength(isChecked), }); - this.autoBeatOptions = UINode.make("div", { - classes: ["beat-group-settings-option-group"], - subs: [ - UINode.make("div", { - classes: ["beat-group-settings-autobeat-option", "beat-group-settings-option"], - subs: [ - this.autoBeatLengthCheckbox.render(), - ], - }), - ] - }); this.remakeBeatSettingsViews(); this.node = UINode.make("div", { classes: ["beat-group-settings"], @@ -118,13 +105,17 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber this.barCountInput.render(), ], }), - this.loopSettingsView.render(), - this.autoBeatOptions, + UINode.make("div", { + classes: ["beat-group-settings-bar-count", "beat-group-settings-option"], + subs: [ + this.autoBeatLengthCheckbox.render(), + ], + }), + this.beatSettingsContainer, UINode.make("button", { innerText: "New Track", onclick: () => this.beatGroup.addBeat(), }), - this.beatSettingsContainer ], }), ], diff --git a/src/ui/BeatLikeLoopSettings/BeatLikeLoopSettings.css b/src/ui/BeatLikeLoopSettings/BeatLikeLoopSettings.css index 58a6406..e69de29 100644 --- a/src/ui/BeatLikeLoopSettings/BeatLikeLoopSettings.css +++ b/src/ui/BeatLikeLoopSettings/BeatLikeLoopSettings.css @@ -1,25 +0,0 @@ -.loop-settings { -} - -.loop-settings-option-group { -} - -.loop-settings-option > label { - width: 5em; - text-align: left; -} - -.loop-settings > p { - margin: 0; - font-weight: bold; - text-align: center; -} - -.loop-settings-option { - display: flex; - justify-content: center; -} - -.loop-settings-option.hide { - display: none; -} \ No newline at end of file diff --git a/src/ui/BeatSettings/BeatSettings.css b/src/ui/BeatSettings/BeatSettings.css index 3dcb822..ed6fd8a 100644 --- a/src/ui/BeatSettings/BeatSettings.css +++ b/src/ui/BeatSettings/BeatSettings.css @@ -1,8 +1,17 @@ .beat-settings { - padding: 1em; - display: inline-flex; + margin-bottom: 0.5em; + display: flex; + height: 3.5em; text-align: center; - justify-content: space-evenly; + align-items: center; + justify-content: space-between; +} + +.beat-settings > * { + margin-right: 0.2em; +} +.beat-settings:last-child { + margin-right: 0; } .beat-settings-time-sig-up { @@ -26,6 +35,19 @@ } .beat-settings-name-field { - margin: auto auto auto auto; width: 5em; +} + +.beat-settings .loop-settings { + text-align: left; + flex: auto; +} + +.beat-settings .loop-settings-option.hide { + display: none; +} + +.beat-settings .loop-settings-option { + flex: auto; + padding-right: 1em; } \ No newline at end of file diff --git a/src/ui/BeatSettings/BeatSettingsView.ts b/src/ui/BeatSettings/BeatSettingsView.ts index a06b1b8..67e3535 100644 --- a/src/ui/BeatSettings/BeatSettingsView.ts +++ b/src/ui/BeatSettings/BeatSettingsView.ts @@ -4,6 +4,9 @@ import UINode, {UINodeOptions} from "../UINode"; import ISubscriber from "../../Subscriber"; import BeatLikeLoopSettingsView from "../BeatLikeLoopSettings/BeatLikeLoopSettingsView"; import {IPublisher} from "../../Publisher"; +import BeatLike from "../../BeatLike"; +import NumberInputView from "../Widgets/NumberInput/NumberInputView"; +import BoolBoxView from "../Widgets/BoolBox/BoolBoxView"; export type BeatSettingsViewUINodeOptions = UINodeOptions & { beat: Beat, @@ -12,7 +15,11 @@ export type BeatSettingsViewUINodeOptions = UINodeOptions & { export default class BeatSettingsView extends UINode implements ISubscriber { private beat: Beat; private nameInput!: HTMLInputElement; - private loopSettingsView!: BeatLikeLoopSettingsView; + private deleteButton!: HTMLButtonElement; + private loopLengthInput!: NumberInputView; + private loopCheckbox!: BoolBoxView; + private loopLengthSection!: HTMLDivElement; + private sub!: { unbind: () => void }; constructor(options: BeatSettingsViewUINodeOptions) { super(options); @@ -21,34 +28,79 @@ export default class BeatSettingsView extends UINode implements ISubscriber { } private setupBindings() { - this.beat.addSubscriber(this, "all"); + this.sub = this.beat.addSubscriber(this, "all"); } setBeat(beat: Beat): void { + this.sub.unbind(); this.beat = beat; - this.loopSettingsView.setBeatLike(beat); + this.setupBindings(); this.notify(null, BeatEvents.NewName); + this.notify(null, BeatEvents.LoopLengthChanged); + this.notify(null, BeatEvents.DisplayTypeChanged); } notify(publisher: IPublisher | null, event: "all" | T[] | T): void { if (event === BeatEvents.NewName) { this.nameInput.value = this.beat.getName(); + } else if (event === BeatEvents.LoopLengthChanged) { + this.loopLengthInput.setValue(this.beat.getLoopLength()); + } else if (event === BeatEvents.DisplayTypeChanged) { + this.loopCheckbox.setValue(this.beat.isLooping()); + if (this.beat.isLooping()) { + this.loopLengthSection.classList.remove("hide"); + } else { + this.loopLengthSection.classList.add("hide"); + } } } rebuild(): HTMLElement { - this.loopSettingsView = new BeatLikeLoopSettingsView({beatLike: this.beat}); this.nameInput = UINode.make("input", { value: this.beat.getName(), classes: ["beat-settings-name-field"], type: "text", oninput: (event: Event) => this.beat.setName((event.target as HTMLInputElement).value), }); + this.deleteButton = UINode.make("button", { + classes: ["beat-settings-delete"], + innerText: "Delete", + onclick: () => this.beat.delete(), + }); + 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 = UINode.make("div", { + classes: ["loop-settings-option"], + subs: [ + this.loopLengthInput.render(), + ], + }); + if (this.beat.isLooping()) { + this.loopLengthSection.classList.remove("hide"); + } else { + this.loopLengthSection.classList.add("hide"); + } this.node = UINode.make("div", { classes: ["beat-settings"], subs: [ this.nameInput, - this.loopSettingsView.render(), + UINode.make("div", { + classes: ["loop-settings"], + subs: [ + this.loopCheckbox.render(), + ] + }), + this.loopLengthSection, + this.deleteButton, ], }); return this.node; diff --git a/src/ui/Root/Root.css b/src/ui/Root/Root.css index 38c40d7..bd3f354 100644 --- a/src/ui/Root/Root.css +++ b/src/ui/Root/Root.css @@ -25,7 +25,7 @@ background-color: var(--color-bg-light); height: 100%; width: 30em; - padding: 0 3em 0 3em; + padding: 0 2em 0 2em; overflow: scroll; display: inline-block; } diff --git a/src/ui/Widgets/NumberInput/NumberInput.css b/src/ui/Widgets/NumberInput/NumberInput.css index 89111ff..cda6b5c 100644 --- a/src/ui/Widgets/NumberInput/NumberInput.css +++ b/src/ui/Widgets/NumberInput/NumberInput.css @@ -3,9 +3,19 @@ } .number-input-label { + display: none; +} + +.number-input-label.top { + display: block; margin-bottom: 0.5em; } +.number-input-label.left { + display: inline-block; + margin-right: 0.5em; +} + input[type="number"].number-input-input { -webkit-appearance: textfield; -moz-appearance: textfield; @@ -41,3 +51,17 @@ input[type="number"].number-input-input::-webkit-outer-spin-button { .number-input-dec { border-radius: 0.5em 0 0 0.5em; } + +.number-input.disabled { + filter: brightness(0.8); +} +.number-input.disabled input[type="number"].number-input-input { + color: var(--color-p-light); + background-color: var(--color-ui-neutral-dark); +} +.number-input.disabled .number-input-button { + cursor: default; +} +.number-input.disabled .number-input-button:hover { + background-color: var(--color-ui-neutral-dark); +} diff --git a/src/ui/Widgets/NumberInput/NumberInputView.ts b/src/ui/Widgets/NumberInput/NumberInputView.ts index 2e53804..b555006 100644 --- a/src/ui/Widgets/NumberInput/NumberInputView.ts +++ b/src/ui/Widgets/NumberInput/NumberInputView.ts @@ -4,6 +4,7 @@ import "./NumberInput.css"; type NumberInputUINodeOptionsBase = UINodeOptions & { label?: string, initialValue?: number, + labelPosition?: "top" | "left", } type NumberInputUINodeOptionsIncDecInput = NumberInputUINodeOptionsBase & { @@ -28,6 +29,7 @@ export default class NumberInputView extends UINode { private labelElement!: HTMLLabelElement; private mainElement!: HTMLDivElement; private inputElement!: HTMLInputElement; + private labelPosition: "top" | "left"; private value: number; private label: string | null; private onIncrement: (() => void) | null; @@ -38,6 +40,7 @@ export default class NumberInputView extends UINode { constructor(options: NumberInputUINodeOptions) { super(options); + this.labelPosition = options.labelPosition ?? "top"; this.label = options.label ?? ""; this.value = options.initialValue ?? 0; this.onDecrement = options.onDecrement ?? null; @@ -59,6 +62,16 @@ export default class NumberInputView extends UINode { } } + disable(): void { + this.mainElement.classList.add("disabled"); + this.inputElement.disabled = true; + } + + enable(): void { + this.mainElement.classList.remove("disabled"); + this.inputElement.disabled = false; + } + setValue(value: number): void { this.value = value; this.inputElement.valueAsNumber = value; @@ -66,7 +79,7 @@ export default class NumberInputView extends UINode { rebuild(): HTMLDivElement { this.labelElement = UINode.make("label", { - classes: ["number-input-label"], + classes: ["number-input-label", this.labelPosition], innerText: this.label ?? "", }); if (this.label !== null) { diff --git a/src/utils.ts b/src/utils.ts index b6734ca..d557be0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,12 @@ export function isPosInt(maybePosInt: number): boolean { return (maybePosInt | 0) === maybePosInt && maybePosInt > 0; +} + +export function greatestCommonDivisor(a: number, b: number): number { + while (b !== 0) { + const temp = b; + b = a % b; + a = temp; + } + return a; } \ No newline at end of file