From 7fa530f0704596f1b44aedaec66ca68244292ff1 Mon Sep 17 00:00:00 2001 From: Daniel Ledda Date: Mon, 30 Aug 2021 14:12:41 +0200 Subject: [PATCH] feat: more ui, looping --- src/Beat.ts | 53 +++++++-- src/BeatGroup.ts | 68 ++++++++--- src/BeatLike.ts | 11 ++ src/BeatUnit.ts | 4 +- src/main.ts | 3 +- src/ui/BeatGroup/Beat/Beat.css | 34 +++++- .../Beat/BeatSettings/BeatSettings.css | 26 +++- .../Beat/BeatSettings/BeatSettingsView.ts | 34 ++++-- src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css | 23 +++- .../BeatGroup/Beat/BeatUnit/BeatUnitView.ts | 58 +++++++-- .../BeatUnitCollection/BeatUnitCollection.css | 0 .../BeatUnitCollectionView.ts | 53 --------- src/ui/BeatGroup/Beat/BeatView.ts | 111 ++++++++++++++---- .../BeatGroupSettings/BeatGroupSettings.css | 13 +- .../BeatGroupSettingsView.ts | 46 +++++--- src/ui/BeatGroup/BeatGroupView.ts | 9 +- .../BeatLikeLoopSettings.css | 17 +++ .../BeatLikeLoopSettingsView.ts | 79 +++++++++++++ src/ui/Root/Root.css | 11 +- src/ui/Root/RootView.ts | 6 +- src/ui/UINode.ts | 33 ++++-- src/utils.ts | 3 + 22 files changed, 529 insertions(+), 166 deletions(-) create mode 100644 src/BeatLike.ts delete mode 100644 src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollection.css delete mode 100644 src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollectionView.ts create mode 100644 src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettings.css create mode 100644 src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettingsView.ts create mode 100644 src/utils.ts diff --git a/src/Beat.ts b/src/Beat.ts index cc64078..0af9f08 100644 --- a/src/Beat.ts +++ b/src/Beat.ts @@ -1,6 +1,8 @@ import BeatUnit, {BeatUnitType} from "./BeatUnit"; import {IPublisher, Publisher} from "./Publisher"; import ISubscriber from "./Subscriber"; +import BeatLike from "./BeatLike"; +import {isPosInt} from "./utils"; export type BeatInitOptions = { timeSig?: { @@ -9,6 +11,8 @@ export type BeatInitOptions = { }, name?: string, bars?: number, + isLooping?: boolean, + loopLength?: number, }; export enum BeatEvents { @@ -16,9 +20,11 @@ export enum BeatEvents { NewBarCount, NewName, UnitChanged, + DisplayTypeChanged, + LoopLengthChanged, } -export default class Beat implements IPublisher{ +export default class Beat implements IPublisher, BeatLike { private static count = 0; private readonly key: string; private name: string; @@ -27,13 +33,30 @@ export default class Beat implements IPublisher{ private readonly unitRecord: BeatUnit[] = []; private barCount = 1; private publisher = new Publisher(); + private loopLength: number; + private looping: boolean; constructor(options?: BeatInitOptions) { this.key = `Beat-${Beat.count}`; this.name = options?.name ?? this.key; this.setTimeSignature({up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4}); - this.setBars(options?.bars ?? 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) { + return; + } + this.loopLength = loopLength | 0; + this.publisher.notifySubs(BeatEvents.LoopLengthChanged); + } + + setLooping(isLooping: boolean): void { + this.looping = isLooping; + this.publisher.notifySubs(BeatEvents.DisplayTypeChanged); } addSubscriber(subscriber: ISubscriber, eventType: BeatEvents | "all"): { unbind: () => void } { @@ -43,6 +66,7 @@ export default class Beat implements IPublisher{ setTimeSignature(timeSig: {up?: number, down?: number}): void { if (timeSig.up && Beat.isValidTimeSigRange(timeSig.up)) { this.timeSigUp = timeSig.up | 0; + this.loopLength = this.timeSigUp * this.barCount; } if (timeSig.down && Beat.isValidTimeSigRange(timeSig.down)) { this.timeSigDown = timeSig.down | 0; @@ -51,17 +75,20 @@ export default class Beat implements IPublisher{ this.publisher.notifySubs(BeatEvents.NewTimeSig); } - setBars(barCount: number): void { - const isPosInt = (barCount > 0 && (barCount | 0) === barCount); - if (!isPosInt || barCount == this.barCount) { + setBarCount(barCount: number): void { + if (!isPosInt(barCount) || barCount == this.barCount) { return; } this.barCount = barCount; + this.loopLength = this.timeSigUp * this.barCount; this.updateBeatUnitLength(); this.publisher.notifySubs(BeatEvents.NewBarCount); } getUnitByIndex(index: number): BeatUnit | null { + if (this.looping) { + return this.unitRecord[index % this.loopLength]; + } return this.unitRecord[index] ?? null; } @@ -86,7 +113,7 @@ export default class Beat implements IPublisher{ } turnUnitOn(index: number): void { - if (Math.abs(index | 0) !== index) { + if (!isPosInt(index)) { return; } const unit = this.getUnit(index); @@ -97,7 +124,7 @@ export default class Beat implements IPublisher{ } turnUnitOff(index: number): void { - if (Math.abs(index | 0) !== index) { + if (!isPosInt(index)) { return; } const unit = this.getUnit(index); @@ -109,7 +136,7 @@ export default class Beat implements IPublisher{ toggleUnit(index: number): void { - if (Math.abs(index | 0) !== index) { + if (!isPosInt(index)) { return; } const unit = this.getUnit(index); @@ -120,7 +147,7 @@ export default class Beat implements IPublisher{ } setUnitType(index: number, type: BeatUnitType): void { - if (Math.abs(index | 0) !== index) { + if (!isPosInt(index)) { return; } this.getUnit(index).setType(type); @@ -162,4 +189,12 @@ export default class Beat implements IPublisher{ getName(): string { return this.name; } + + isLooping(): boolean { + return this.looping; + } + + getLoopLength(): number { + return this.loopLength; + } } \ No newline at end of file diff --git a/src/BeatGroup.ts b/src/BeatGroup.ts index e24dce1..b46ebd2 100644 --- a/src/BeatGroup.ts +++ b/src/BeatGroup.ts @@ -1,9 +1,15 @@ -import Beat, {BeatInitOptions} from "./Beat"; +import Beat, {BeatEvents, BeatInitOptions} from "./Beat"; import {IPublisher, Publisher} from "./Publisher"; import ISubscriber from "./Subscriber"; +import BeatLike from "./BeatLike"; +import {isPosInt} from "./utils"; type BeatGroupInitOptions = { + barCount: number; + isLooping: boolean; + timeSigUp: number; beats: BeatInitOptions[], + loopLength?: number, } export const enum BeatGroupEvents { @@ -13,12 +19,14 @@ export const enum BeatGroupEvents { GlobalTimeSigUpChanged, } -export default class BeatGroup implements IPublisher { +export default class BeatGroup implements IPublisher, BeatLike { private beats: Beat[] = []; private beatKeyMap: Record = {}; - private publisher: Publisher = new Publisher(); - private lastGlobalBarCount: number; - private lastGlobalTimeSigUp: number; + private publisher: Publisher = new Publisher(); + private globalBarCount: number; + private globalTimeSigUp: number; + private globalLoopLength: number; + private globalIsLooping: boolean; constructor(options?: BeatGroupInitOptions) { if (options?.beats) { @@ -27,34 +35,64 @@ export default class BeatGroup implements IPublisher { this.beats.push(newBeat); this.beatKeyMap[newBeat.getKey()] = this.beats.length - 1; } - this.lastGlobalBarCount = this.beats[0].getBarCount(); - this.lastGlobalTimeSigUp = this.beats[0].getTimeSigUp(); - } else { - this.lastGlobalBarCount = 4; - this.lastGlobalTimeSigUp = 4; } + this.globalBarCount = options?.barCount ?? 4; + this.globalTimeSigUp = options?.timeSigUp ?? 4; + this.globalLoopLength = options?.loopLength ?? this.globalBarCount * this.globalTimeSigUp; + this.globalIsLooping = options?.isLooping ?? false; } - addSubscriber(subscriber: ISubscriber, eventType: "all" | BeatGroupEvents | BeatGroupEvents[]): { unbind: () => void } { + addSubscriber(subscriber: ISubscriber, eventType: "all" | BeatGroupEvents | BeatEvents | (BeatGroupEvents | BeatEvents)[]): { unbind: () => void } { return this.publisher.addSubscriber(subscriber, eventType); } - setGlobalBarCount(barCount: number): void { + setBarCount(barCount: number): void { if (barCount <= 0 || (barCount | 0) !== barCount) { return; } - this.lastGlobalBarCount = barCount; + this.globalBarCount = barCount; for (const beat of this.beats) { - beat.setBars(barCount); + beat.setBarCount(barCount); } this.publisher.notifySubs(BeatGroupEvents.GlobalBarCountChanged); } + getBarCount(): number { + return this.globalBarCount; + } + + setLoopLength(loopLength: number): void { + if (!isPosInt(loopLength)) { + return; + } + this.globalLoopLength = loopLength; + for (const beat of this.beats) { + beat.setLoopLength(loopLength); + } + this.publisher.notifySubs(BeatEvents.LoopLengthChanged); + } + + getLoopLength(): number { + return this.globalLoopLength; + } + + setLooping(isLooping: boolean): void { + this.globalIsLooping = isLooping; + for (const beat of this.beats) { + beat.setLooping(isLooping); + } + this.publisher.notifySubs(BeatEvents.DisplayTypeChanged); + } + + isLooping(): boolean { + return this.globalIsLooping; + } + setGlobalTimeSigUp(timeSigUp: number): void { if (!Beat.isValidTimeSigRange(timeSigUp)) { return; } - this.lastGlobalTimeSigUp = timeSigUp; + this.globalTimeSigUp = timeSigUp; for (const beat of this.beats) { beat.setTimeSignature({up: timeSigUp}); } diff --git a/src/BeatLike.ts b/src/BeatLike.ts new file mode 100644 index 0000000..b32fa86 --- /dev/null +++ b/src/BeatLike.ts @@ -0,0 +1,11 @@ +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 index bf4a707..d853ff5 100644 --- a/src/BeatUnit.ts +++ b/src/BeatUnit.ts @@ -40,12 +40,12 @@ export default class BeatUnit implements IPublisher { setOn(on: boolean): void { this.on = on; - this.publisher.notifySubs(BeatUnitEvents.On); + this.publisher.notifySubs(this.on ? BeatUnitEvents.On : BeatUnitEvents.Off); } setType(type: BeatUnitType): void { this.type = type; - this.publisher.notifySubs(BeatUnitEvents.Off); + this.publisher.notifySubs(BeatUnitEvents.TypeChange); } getType(): BeatUnitType { diff --git a/src/main.ts b/src/main.ts index 758be1b..8c2ffe8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,13 +28,12 @@ const appNode = document.querySelector("#app"); if (appNode) { const appRoot = new RootView({ - parent: appNode as HTMLDivElement, title: "Drum Slayer", mainBeatGroup: mainBeatGroup, }); //@ts-ignore window.appRoot = appRoot; - appRoot.render(); + appNode.appendChild(appRoot.render()); console.log("OK!"); } else { console.error("FUCK!"); diff --git a/src/ui/BeatGroup/Beat/Beat.css b/src/ui/BeatGroup/Beat/Beat.css index 299a603..4c95ee1 100644 --- a/src/ui/BeatGroup/Beat/Beat.css +++ b/src/ui/BeatGroup/Beat/Beat.css @@ -12,16 +12,31 @@ } .beat-settings-btn { - margin: 0; + cursor: pointer; + line-height: 2em; display: inline-block; + height: 2em; + border: 1px solid grey; + border-radius: 1em; + user-select: none; + transition: background-color 150ms; +} + +.beat-settings-btn:hover { + background-color: #eaeaea; +} + +.beat-settings-btn.active { + background-color: lightgrey; + transition: none; } .beat-unit-block { - display: inline-block; + height: 2em; } .beat-title { - display: inline-block; + line-height: 2em; width: 3em; margin: 0; } @@ -29,4 +44,17 @@ .beat-spacer { display: inline-block; width: 1em; + height: 2em; +} + +.beat-main { + display: inline-flex; +} + +.beat-settings-container { + display: flex; +} + +.beat { + width: max-content; } \ No newline at end of file diff --git a/src/ui/BeatGroup/Beat/BeatSettings/BeatSettings.css b/src/ui/BeatGroup/Beat/BeatSettings/BeatSettings.css index e275bd9..e343aa5 100644 --- a/src/ui/BeatGroup/Beat/BeatSettings/BeatSettings.css +++ b/src/ui/BeatGroup/Beat/BeatSettings/BeatSettings.css @@ -1,7 +1,31 @@ .beat-settings { + padding: 1em; display: none; + text-align: center; + width: 40em; + justify-content: space-evenly; } .beat-settings.visible { + display: inline-flex; +} + +.beat-settings-time-sig-up { display: block; -} \ No newline at end of file +} + +.beat-settings-time-sig-down { + display: block; +} + +.beat-settings-option { + display: inline-block; +} + +.beat-settings-option-group { + align-self: stretch; +} + +.beat-settings-time-sig input { + margin: auto; +} diff --git a/src/ui/BeatGroup/Beat/BeatSettings/BeatSettingsView.ts b/src/ui/BeatGroup/Beat/BeatSettings/BeatSettingsView.ts index 51b3dbf..d83fd86 100644 --- a/src/ui/BeatGroup/Beat/BeatSettings/BeatSettingsView.ts +++ b/src/ui/BeatGroup/Beat/BeatSettings/BeatSettingsView.ts @@ -3,6 +3,8 @@ import Beat, {BeatEvents} from "../../../../Beat"; import {IPublisher} from "../../../../Publisher"; import ISubscriber from "../../../../Subscriber"; import "./BeatSettings.css"; +import BeatLike from "../../../../BeatLike"; +import BeatLikeLoopSettingsView from "../../BeatLikeLoopSettings/BeatLikeLoopSettingsView"; export type BeatSettingsViewUINodeOptions = UINodeOptions & { beat: Beat, @@ -14,6 +16,7 @@ export default class BeatSettingsView extends UINode implements ISubscriber { private timeSigUp!: HTMLInputElement; private timeSigDown!: HTMLInputElement; private barCountInput!: HTMLInputElement; + private loopSettingsView!: BeatLikeLoopSettingsView; constructor(options: BeatSettingsViewUINodeOptions) { super(options); @@ -25,7 +28,7 @@ export default class BeatSettingsView extends UINode implements ISubscriber { this.beat.addSubscriber(this, "all"); } - notify(publisher: IPublisher, event: "all" | T[] | T) { + notify(publisher: IPublisher, event: "all" | T[] | T): void { if (event === BeatEvents.NewTimeSig) { this.timeSigUp.value = this.beat.getTimeSigUp().toString(); this.timeSigDown.value = this.beat.getTimeSigDown().toString(); @@ -48,38 +51,51 @@ export default class BeatSettingsView extends UINode implements ISubscriber { } rebuild(): HTMLElement { + this.loopSettingsView = new BeatLikeLoopSettingsView({beatLike: this.beat}); this.timeSigUp = UINode.make("input", { + classes: ["time-sig-up"], + type: "number", value: this.beat.getTimeSigUp().toString(), - oninput: (event) => { + oninput: (event: Event) => { this.beat.setTimeSignature({up: Number((event.target as HTMLInputElement).value) }); }, }); this.timeSigDown = UINode.make("input", { + classes: ["beat-settings-time-sig-down"], + type: "number", value: this.beat.getTimeSigDown().toString(), - oninput: (event) => { + oninput: (event: Event) => { this.beat.setTimeSignature({down: Number((event.target as HTMLInputElement).value) }); }, }); this.barCountInput = UINode.make("input", { + classes: ["beat-settings-bars-count"], + type: "number", value: this.beat.getBarCount().toString(), - oninput: (event) => { - this.beat.setBars(Number((event.target as HTMLInputElement).value)); + oninput: (event: Event) => { + this.beat.setBarCount(Number((event.target as HTMLInputElement).value)); }, }); this.node = UINode.make("div", { + classes: ["beat-settings"], subs: [ UINode.make("div", { - classes: ["beat-settings-time-sig"], + classes: ["beat-settings-time-sig", "beat-settings-option-group", "beat-settings-option"], subs: [ UINode.make("label", {innerText: "Time Signature:"}), this.timeSigUp, this.timeSigDown, ] }), - UINode.make("label", {innerText: "Bars:"}), - this.barCountInput, + UINode.make("div", { + classes: ["beat-settings-bar", "beat-settings-option-group", "beat-settings-option"], + subs: [ + UINode.make("label", {innerText: "Bar Count:"}), + this.barCountInput, + ], + }), + this.loopSettingsView.render(), ], - classes: ["beat-settings"] }); return this.node; } diff --git a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css index c12c95c..d551d6d 100644 --- a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css +++ b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnit.css @@ -1,11 +1,30 @@ .beat-unit { width: 2em; height: 2em; + margin-right: 0.2em; background-color: white; - border: 0.1em solid black; + border-width: 0.1em 0.1em 0.1em 0.1em; + border-color: black; + border-style: solid; display: inline-block; + transition: background-color 150ms; + cursor: pointer; } -.beat-unit.on { +.beat-unit:hover { + background-color: #f1c3b6; + transition: none; +} + +.beat-unit.beat-unit-ghost:hover { + background-color: #c9e2c9; +} + +.beat-unit.beat-unit-on { background-color: darksalmon; + transition: none; +} + +.beat-unit.beat-unit-on.beat-unit-ghost { + background-color: darkseagreen; } \ No newline at end of file diff --git a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnitView.ts b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnitView.ts index 40bd8e0..a8d3d8b 100644 --- a/src/ui/BeatGroup/Beat/BeatUnit/BeatUnitView.ts +++ b/src/ui/BeatGroup/Beat/BeatUnit/BeatUnitView.ts @@ -1,4 +1,4 @@ -import BeatUnit, {BeatUnitEvents} from "../../../../BeatUnit"; +import BeatUnit, {BeatUnitEvents, BeatUnitType} from "../../../../BeatUnit"; import ISubscriber from "../../../../Subscriber"; import UINode, {UINodeOptions} from "../../../UINode"; import {IPublisher} from "../../../../Publisher"; @@ -10,37 +10,79 @@ export type BeatUnitUINodeOptions = UINodeOptions & { export default class BeatUnitView extends UINode implements ISubscriber { private beatUnit: BeatUnit; + private subscription!: {unbind: () => void}; constructor(options: BeatUnitUINodeOptions) { super(options); this.beatUnit = options.beatUnit; this.setupBindings(); + } + + setUnit(beatUnit: BeatUnit): void { + this.subscription.unbind(); + this.beatUnit = beatUnit; + this.setupBindings(); this.rebuild(); + this.redraw(); } private setupBindings() { - this.beatUnit.addSubscriber(this, "all"); + this.subscription = this.beatUnit.addSubscriber(this, "all"); } - notify(publisher: IPublisher, event: "all" | T[] | T) { + toggle(): void { + this.beatUnit.toggle(); + } + + turnOn(): void { + this.beatUnit.setOn(true); + } + + turnOff(): void { + this.beatUnit.setOn(false); + } + + notify(publisher: IPublisher, event: "all" | T[] | T): void { if (event === BeatUnitEvents.On) { - this.node?.classList.add("on"); + this.node?.classList.add("beat-unit-on"); } else if (event === BeatUnitEvents.Off) { - this.node?.classList.remove("on"); + this.node?.classList.remove("beat-unit-on"); + } else if (event === BeatUnitEvents.TypeChange) { + if (this.beatUnit.getType() === BeatUnitType.GhostNote) { + this.node?.classList.add("beat-unit-ghost"); + } else { + this.node?.classList.remove("beat-unit-ghost"); + } } } rebuild(): HTMLElement { const classes = ["beat-unit"]; if (this.beatUnit.isOn()) { - classes.push("on"); + classes.push("beat-unit-on"); } this.node = UINode.make("div", { classes: classes, - onclick: () => { - this.beatUnit.toggle(); + oncontextmenu: () => false, + }); + this.onMouseUp((ev: MouseEvent) => { + if (ev.button === 1) { + const currentType = this.beatUnit.getType(); + this.beatUnit.setType(currentType === BeatUnitType.GhostNote ? BeatUnitType.Normal : BeatUnitType.GhostNote); } }); return this.node; } + + onHover(cb: () => void): void { + this.getNode().onmouseover = cb; + } + + onMouseDown(cb: (ev: MouseEvent) => void): void { + this.getNode().onmousedown = cb; + } + + onMouseUp(cb: (ev: MouseEvent) => void): void { + this.getNode().onmouseup = cb; + } } diff --git a/src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollection.css b/src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollection.css deleted file mode 100644 index e69de29..0000000 diff --git a/src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollectionView.ts b/src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollectionView.ts deleted file mode 100644 index b9f9be6..0000000 --- a/src/ui/BeatGroup/Beat/BeatUnitCollection/BeatUnitCollectionView.ts +++ /dev/null @@ -1,53 +0,0 @@ -import ISubscriber from "../../../../Subscriber"; -import UINode, {UINodeOptions} from "../../../UINode"; -import BeatGroup, {BeatGroupEvents} from "../../../../BeatGroup"; -import {IPublisher} from "../../../../Publisher"; - -export type BeatUnitCollectionUINodeOptions = UINodeOptions & { - beatGroup: BeatGroup, -}; - -export default class BeatUnitCollectionView extends UINode implements ISubscriber { - private beatGroup: BeatGroup; - private barCountInput!: HTMLInputElement; - private timeSigUpInput!: HTMLInputElement; - - constructor(options: BeatUnitCollectionUINodeOptions) { - super(options); - this.beatGroup = options.beatGroup; - this.beatGroup.addSubscriber(this, []); - this.rebuild(); - } - - notify(publisher: IPublisher, event: "all" | T[] | T): void { - if (event === BeatGroupEvents.GlobalBarCountChanged) { - this.barCountInput.value = this.beatGroup.getBeatByIndex(0).getBarCount().toString(); - } else if (event === BeatGroupEvents.GlobalTimeSigUpChanged) { - this.barCountInput.value = this.beatGroup.getBeatByIndex(0).getBarCount().toString(); - } - } - - rebuild(): HTMLElement { - this.barCountInput = UINode.make("input", { - type: "text", - classes: ["beat-group-settings-view-bar-count"], - value: this.beatGroup.getBeatByIndex(0).getBarCount(), - oninput: () => { - this.beatGroup.setGlobalBarCount(Number(this.barCountInput.value)); - }, - }); - this.timeSigUpInput = UINode.make("input", { - type: "text", - value: this.beatGroup.getBeatByIndex(0).getTimeSigUp(), - classes: ["beat-group-settings-view-time-sig-up"], - oninput: () => { - this.beatGroup.setGlobalTimeSigUp(Number(this.timeSigUpInput.value)); - }, - }); - this.node = UINode.make("div", { - subs: [ - ], - }); - return this.node; - } -} \ No newline at end of file diff --git a/src/ui/BeatGroup/Beat/BeatView.ts b/src/ui/BeatGroup/Beat/BeatView.ts index b53b21a..5a7ff3e 100644 --- a/src/ui/BeatGroup/Beat/BeatView.ts +++ b/src/ui/BeatGroup/Beat/BeatView.ts @@ -14,15 +14,17 @@ export default class BeatView extends UINode implements ISubscriber { private beat: Beat; private title!: HTMLHeadingElement; private settingsView!: BeatSettingsView; - private settingsToggleButton!: HTMLButtonElement; + private settingsToggleButton!: HTMLDivElement; private beatUnitViews: BeatUnitView[] = []; - private beatUnitViewBlock!: HTMLElement; + private beatUnitViewBlock: HTMLElement | null = null; + private lastHoveredBeatUnitView: BeatUnitView | null = null; + private static deselectingUnits = false; + private static selectingUnits = false; constructor(options: BeatUINodeOptions) { super(options); this.beat = options.beat; this.setupBindings(); - this.rebuild(); } private setupBindings() { @@ -33,29 +35,85 @@ export default class BeatView extends UINode implements ISubscriber { if (event === BeatEvents.NewName) { this.title.innerText = this.beat.getName(); } else if (event === BeatEvents.NewTimeSig) { - this.render(); + this.setupBeatUnits(); } else if (event === BeatEvents.NewBarCount) { - this.render(); + this.setupBeatUnits(); + } else if (event === BeatEvents.DisplayTypeChanged) { + this.setupBeatUnits(); + } else if (event === BeatEvents.LoopLengthChanged) { + this.setupBeatUnits(); } } private toggleSettings() { this.settingsView.toggleVisible(); - this.settingsToggleButton.innerText = this.settingsView.isOpen() ? "Hide Settings" : "Show Settings"; + if (this.settingsView.isOpen()) { + this.settingsToggleButton.classList.add("active"); + } else { + this.settingsToggleButton.classList.remove("active"); + } } - private makeBeatUnits() { + private rebuildBeatUnitViews() { const beatUnitCount = this.beat.getBarCount() * this.beat.getTimeSigUp(); - this.beatUnitViews = []; + this.beatUnitViews.splice(beatUnitCount, this.beatUnitViews.length - beatUnitCount); for (let i = 0; i < beatUnitCount; i++) { const beatUnit = this.beat.getUnitByIndex(i); if (beatUnit) { - this.beatUnitViews.push(new BeatUnitView({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.onMouseDown((event: MouseEvent) => this.onBeatUnitClick(event.button, i)); + window.addEventListener("mouseup", (event: MouseEvent) => { + BeatView.selectingUnits = false; + BeatView.deselectingUnits = false; + }); + view.onHover(() => { + this.lastHoveredBeatUnitView = view; + if (BeatView.selectingUnits) { + this.lastHoveredBeatUnitView.turnOn(); + } else if (BeatView.deselectingUnits) { + this.lastHoveredBeatUnitView.turnOff(); + } + }); } } } + 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 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 = UINode.make("div", { + classes: ["beat-unit-block"], + subs: [...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(); @@ -79,32 +137,39 @@ export default class BeatView extends UINode implements ISubscriber { } } + private setupBeatUnits(): void { + this.rebuildBeatUnitViews(); + this.buildBeatUnitViewBlock(); + this.respaceBeatUnits(); + } + rebuild(): HTMLElement { this.title = UINode.make("h3", { innerText: this.beat.getName(), classes: ["beat-title"], }); - this.makeBeatUnits(); + this.setupBeatUnits(); this.settingsView = new BeatSettingsView({beat: this.beat}); - this.settingsToggleButton = UINode.make("button", { + this.settingsToggleButton = UINode.make("div", { classes: ["beat-settings-btn"], - innerText: this.settingsView.isOpen() ? "Hide Settings" : "Show Settings", + innerText: "Settings", + onclick: () => this.toggleSettings() }); - this.settingsToggleButton.addEventListener("click", () => this.toggleSettings()); - this.beatUnitViewBlock = UINode.make("div", { - classes: ["beat-unit-block"], - subs: [ - ...this.beatUnitViews.map(view => view.rebuild()), - ], - }); - this.respaceBeatUnits(); this.node = UINode.make("div", { classes: ["beat"], subs: [ - this.title, - this.beatUnitViewBlock, + UINode.make("div", { + classes: ["beat-main"], + subs: [ + this.title, + this.beatUnitViewBlock!, + ] + }), this.settingsToggleButton, - this.settingsView.rebuild(), + UINode.make("div", { + classes: ["beat-settings-container"], + subs: [this.settingsView.render()], + }), ], }); return this.node; diff --git a/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettings.css b/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettings.css index eb62264..5f3ca84 100644 --- a/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettings.css +++ b/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettings.css @@ -1,3 +1,14 @@ .beat-group-settings { +} -} \ No newline at end of file +.beat-group-settings-options { + padding: 1em; + display: inline-flex; + width: 40em; + justify-content: space-evenly; +} + +.beat-group-settings-option { + display: inline-block; + text-align: center; +} diff --git a/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettingsView.ts b/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettingsView.ts index f5fd8ca..1366d82 100644 --- a/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettingsView.ts +++ b/src/ui/BeatGroup/BeatGroupSettings/BeatGroupSettingsView.ts @@ -3,6 +3,8 @@ import UINode, {UINodeOptions} from "../../UINode"; import ISubscriber from "../../../Subscriber"; import {IPublisher} from "../../../Publisher"; import {BeatGroupEvents} from "../../../BeatGroup"; +import BeatLikeLoopSettingsView from "../BeatLikeLoopSettings/BeatLikeLoopSettingsView"; +import "./BeatGroupSettings.css"; export type BeatGroupSettingsUINodeOptions = UINodeOptions & { beatGroup: BeatGroup, @@ -12,6 +14,7 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber private beatGroup: BeatGroup; private barCountInput!: HTMLInputElement; private timeSigUpInput!: HTMLInputElement; + private loopSettingsView!: BeatLikeLoopSettingsView; constructor(options: BeatGroupSettingsUINodeOptions) { super(options); @@ -20,7 +23,6 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber BeatGroupEvents.GlobalBarCountChanged, BeatGroupEvents.GlobalTimeSigUpChanged ]); - this.rebuild(); } notify(publisher: IPublisher, event: "all" | T[] | T): void { @@ -32,33 +34,45 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber } rebuild(): HTMLElement { + this.loopSettingsView = new BeatLikeLoopSettingsView({beatLike: this.beatGroup}); this.barCountInput = UINode.make("input", { - type: "text", - classes: ["beat-group-settings-view-bar-count"], - value: this.beatGroup.getBeatByIndex(0).getBarCount(), + type: "number", + value: this.beatGroup.getBeatByIndex(0).getBarCount().toString(), oninput: () => { - this.beatGroup.setGlobalBarCount(Number(this.barCountInput.value)); + this.beatGroup.setBarCount(Number(this.barCountInput.value)); }, }); this.timeSigUpInput = UINode.make("input", { - type: "text", - value: this.beatGroup.getBeatByIndex(0).getTimeSigUp(), - classes: ["beat-group-settings-view-time-sig-up"], + type: "number", + value: this.beatGroup.getBeatByIndex(0).getTimeSigUp().toString(), oninput: () => { this.beatGroup.setGlobalTimeSigUp(Number(this.timeSigUpInput.value)); }, }); this.node = UINode.make("div", { + classes: ["beat-group-settings"], subs: [ - UINode.make("h4", {innerText: "Settings for beat"}), - UINode.make("label", { - innerText: "Bars:", + UINode.make("h4", { innerText: "Settings for beat" }), + UINode.make("div", { + classes: ["beat-group-settings-options"], + subs: [ + UINode.make("div", { + classes: ["beat-group-settings-bar-count", "beat-group-settings-option"], + subs: [ + UINode.make("label", { innerText: "Bars:" }), + this.barCountInput, + ], + }), + UINode.make("div", { + classes: ["beat-group-settings-boxes", "beat-group-settings-option"], + subs: [ + UINode.make("label", { innerText: "Boxes per bar:" }), + this.timeSigUpInput, + ], + }), + this.loopSettingsView.render(), + ], }), - this.barCountInput, - UINode.make("label", { - innerText: "Boxes per bar:", - }), - this.timeSigUpInput, ], }); return this.node; diff --git a/src/ui/BeatGroup/BeatGroupView.ts b/src/ui/BeatGroup/BeatGroupView.ts index 4f5d621..d5f9460 100644 --- a/src/ui/BeatGroup/BeatGroupView.ts +++ b/src/ui/BeatGroup/BeatGroupView.ts @@ -12,6 +12,7 @@ export default class BeatGroupView extends UINode { private title: string; private beatGroup: BeatGroup; private beatGroupSettingsView!: BeatGroupSettingsView; + private beatViews: BeatView[] = []; constructor(options: BeatGroupUINodeOptions) { super(options); @@ -20,17 +21,17 @@ export default class BeatGroupView extends UINode { } rebuild(): HTMLDivElement { - const beatViews = []; + this.beatViews = []; for (let i = 0; i < this.beatGroup.getBeatCount(); i++) { - beatViews.push(new BeatView({beat: this.beatGroup.getBeatByIndex(i)})); + this.beatViews.push(new BeatView({beat: this.beatGroup.getBeatByIndex(i)})); } this.beatGroupSettingsView = new BeatGroupSettingsView({beatGroup: this.beatGroup}); return UINode.make("div", { classes: ["beat-group"], subs: [ UINode.make("h3", {innerText: this.title}), - this.beatGroupSettingsView.rebuild(), - ...beatViews.map(bv => bv.rebuild()) + this.beatGroupSettingsView.render(), + ...this.beatViews.map(bv => bv.render()) ], }); } diff --git a/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettings.css b/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettings.css new file mode 100644 index 0000000..28b2c13 --- /dev/null +++ b/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettings.css @@ -0,0 +1,17 @@ +.loop-settings-option-group { + margin-top: 1em; +} + +.loop-settings-option > label { + width: 5em; + text-align: left; +} + +.loop-settings > p { + margin: 0; + text-align: center; +} + +.loop-settings-option { + display: flex; +} \ No newline at end of file diff --git a/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettingsView.ts b/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettingsView.ts new file mode 100644 index 0000000..92698eb --- /dev/null +++ b/src/ui/BeatGroup/BeatLikeLoopSettings/BeatLikeLoopSettingsView.ts @@ -0,0 +1,79 @@ +import BeatLike from "../../../BeatLike"; +import UINode, {UINodeOptions} from "../../UINode"; +import ISubscriber from "../../../Subscriber"; +import {IPublisher} from "../../../Publisher"; +import {BeatEvents} from "../../../Beat"; +import "./BeatLikeLoopSettings.css"; + +export type BeatLikeLoopSettingsViewUINodeOptions = UINodeOptions & { + beatLike: BeatLike, +}; + +export default class BeatLikeLoopSettingsView extends UINode implements ISubscriber { + private beatLike: BeatLike; + private loopLengthInput!: HTMLInputElement; + private loopCheckbox!: HTMLInputElement; + + constructor(options: BeatLikeLoopSettingsViewUINodeOptions) { + super(options); + this.beatLike = options.beatLike; + this.setupBindings(); + } + + private setupBindings() { + this.beatLike.addSubscriber(this, "all"); + } + + notify(publisher: IPublisher, event: "all" | T[] | T): void { + if (event === BeatEvents.LoopLengthChanged) { + this.loopLengthInput.value = this.beatLike.getLoopLength().toString(); + } else if (event === BeatEvents.DisplayTypeChanged) { + this.loopCheckbox.checked = this.beatLike.isLooping(); + } + } + + rebuild(): HTMLElement { + this.loopLengthInput = UINode.make("input", { + classes: ["loop-settings-loop-length"], + type: "number", + value: this.beatLike.getLoopLength().toString(), + oninput: (event: Event) => { + this.beatLike.setLoopLength(Number((event.target as HTMLInputElement).value)); + }, + }); + this.loopCheckbox = UINode.make("input", { + classes: ["loop-settings-loop-toggle"], + type: "checkbox", + checked: this.beatLike.isLooping(), + oninput: (event: Event) => { + this.beatLike.setLooping((event.target as HTMLInputElement).checked); + }, + }); + this.node = UINode.make("div", { + classes: ["loop-settings"], + subs: [ + UINode.make("p", {innerText: "Looping:"}), + UINode.make("div", { + classes: ["loop-settings-option-group"], + subs: [ + UINode.make("div", { + classes: ["loop-settings-option"], + subs: [ + UINode.make("label", {innerText: "Length:"}), + this.loopLengthInput, + ], + }), + UINode.make("div", { + classes: ["loop-settings-option"], + subs: [ + UINode.make("label", {innerText: "On:"}), + this.loopCheckbox, + ], + }), + ], + }), + ] + }); + return this.node; + } +} diff --git a/src/ui/Root/Root.css b/src/ui/Root/Root.css index ebacdee..2a4c7c2 100644 --- a/src/ui/Root/Root.css +++ b/src/ui/Root/Root.css @@ -1,12 +1,17 @@ .root { - margin: auto; - width: 80%; + margin-left: 10em; + margin-right: 10em; } .root .title { text-align: center; } -.root input { +.root input[type="number"] { width: 5em; +} + +* { + user-drag: none; + user-select: none; } \ No newline at end of file diff --git a/src/ui/Root/RootView.ts b/src/ui/Root/RootView.ts index 7985701..a33bd34 100644 --- a/src/ui/Root/RootView.ts +++ b/src/ui/Root/RootView.ts @@ -6,12 +6,10 @@ import "./Root.css"; export type RootUINodeOptions = UINodeOptions & { title: string, mainBeatGroup: BeatGroup, - parent: HTMLElement, }; export default class RootView extends UINode { private title: string; - protected parent: HTMLElement; private beatGroupView: BeatGroupView; private mainBeatGroup: BeatGroup; @@ -20,8 +18,6 @@ export default class RootView extends UINode { this.beatGroupView = new BeatGroupView({title: "THE BEAT", beatGroup: options.mainBeatGroup}); this.mainBeatGroup = options.mainBeatGroup; this.title = options.title; - this.parent = options.parent; - this.rebuild(); } rebuild(): HTMLDivElement { @@ -29,7 +25,7 @@ export default class RootView extends UINode { classes: ["root"], subs: [ UINode.make("h1", {innerText: this.title, classes: ["title"]}), - this.beatGroupView.rebuild(), + this.beatGroupView.render(), ], }); } diff --git a/src/ui/UINode.ts b/src/ui/UINode.ts index aa73ad8..ed70945 100644 --- a/src/ui/UINode.ts +++ b/src/ui/UINode.ts @@ -12,20 +12,33 @@ type IRenderAttributes< export default abstract class UINode { protected node: HTMLElement | null = null; - protected parent: HTMLElement | null = null; constructor(options: UINodeOptions) {} - render(): void { - const oldNode = this.node; - this.node = this.rebuild(); - if (oldNode) { - if (!this.parent) { - this.parent = oldNode.parentElement; - } - this.parent!.replaceChild(this.node, oldNode); + render(): HTMLElement { + if (!this.node) { + this.node = this.rebuild(); + } + return this.node; + } + + protected getNode(): HTMLElement { + if (!this.node) { + return this.render(); } else { - this.parent!.appendChild(this.node); + return this.node; + } + } + + redraw(): void { + const oldNode = this.node; + this.render(); + if (!oldNode || !this.node) { + return; + } + const parent = this.node.parentElement; + if (parent) { + parent.replaceChild(oldNode, this.node); } } diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..b6734ca --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,3 @@ +export function isPosInt(maybePosInt: number): boolean { + return (maybePosInt | 0) === maybePosInt && maybePosInt > 0; +} \ No newline at end of file