From 1861403f2443e6a0f2d922cdf5eef58176d60519 Mon Sep 17 00:00:00 2001 From: Daniel Ledda Date: Sun, 17 Apr 2022 14:08:38 +0200 Subject: [PATCH] feat: auto-save and multiple tracks --- {src/ui => assets/fonts}/DMSans-Bold.ttf | Bin .../ui => assets/fonts}/DMSans-BoldItalic.ttf | Bin {src/ui => assets/fonts}/DMSans-Italic.ttf | Bin {src/ui => assets/fonts}/DMSans-Medium.ttf | Bin .../fonts}/DMSans-MediumItalic.ttf | Bin {src/ui => assets/fonts}/DMSans-Regular.ttf | Bin .../Icon => assets}/svgs/arrow-clockwise.svg | 0 {src/ui/Widgets/Icon => assets}/svgs/list.svg | 0 .../Icon => assets}/svgs/snowflake.svg | 0 .../ui/Widgets/Icon => assets}/svgs/trash.svg | 0 src/Beat.ts | 118 ++++++++++++----- src/BeatStore.ts | 124 ++++++++++++++++++ src/Publisher.ts | 6 +- src/Ref.ts | 43 ++++-- src/Store.ts | 10 -- src/Track.ts | 76 ++++++++++- src/TrackUnit.ts | 17 ++- src/ui/Beat/Beat.css | 21 ++- src/ui/Beat/BeatView.ts | 46 +++++-- src/ui/BeatSettings/BeatSettingsView.ts | 2 +- src/ui/Root/Root.css | 37 +++++- src/ui/Root/RootView.ts | 91 ++++++++----- src/ui/StageTitleBar/StageTitleBar.css | 20 --- src/ui/StageTitleBar/StageTitleBarView.ts | 54 -------- src/ui/Track/TrackView.ts | 2 +- src/ui/TrackSettings/TrackSettingsView.ts | 2 +- src/ui/TrackUnit/TrackUnitView.ts | 2 +- src/ui/Widgets/Icon/IconView.ts | 9 +- src/ui/global.css | 8 +- tsconfig.json | 8 +- webpack.config.js | 3 +- 31 files changed, 498 insertions(+), 201 deletions(-) rename {src/ui => assets/fonts}/DMSans-Bold.ttf (100%) rename {src/ui => assets/fonts}/DMSans-BoldItalic.ttf (100%) rename {src/ui => assets/fonts}/DMSans-Italic.ttf (100%) rename {src/ui => assets/fonts}/DMSans-Medium.ttf (100%) rename {src/ui => assets/fonts}/DMSans-MediumItalic.ttf (100%) rename {src/ui => assets/fonts}/DMSans-Regular.ttf (100%) rename {src/ui/Widgets/Icon => assets}/svgs/arrow-clockwise.svg (100%) rename {src/ui/Widgets/Icon => assets}/svgs/list.svg (100%) rename {src/ui/Widgets/Icon => assets}/svgs/snowflake.svg (100%) rename {src/ui/Widgets/Icon => assets}/svgs/trash.svg (100%) create mode 100644 src/BeatStore.ts delete mode 100644 src/Store.ts delete mode 100644 src/ui/StageTitleBar/StageTitleBar.css delete mode 100644 src/ui/StageTitleBar/StageTitleBarView.ts diff --git a/src/ui/DMSans-Bold.ttf b/assets/fonts/DMSans-Bold.ttf similarity index 100% rename from src/ui/DMSans-Bold.ttf rename to assets/fonts/DMSans-Bold.ttf diff --git a/src/ui/DMSans-BoldItalic.ttf b/assets/fonts/DMSans-BoldItalic.ttf similarity index 100% rename from src/ui/DMSans-BoldItalic.ttf rename to assets/fonts/DMSans-BoldItalic.ttf diff --git a/src/ui/DMSans-Italic.ttf b/assets/fonts/DMSans-Italic.ttf similarity index 100% rename from src/ui/DMSans-Italic.ttf rename to assets/fonts/DMSans-Italic.ttf diff --git a/src/ui/DMSans-Medium.ttf b/assets/fonts/DMSans-Medium.ttf similarity index 100% rename from src/ui/DMSans-Medium.ttf rename to assets/fonts/DMSans-Medium.ttf diff --git a/src/ui/DMSans-MediumItalic.ttf b/assets/fonts/DMSans-MediumItalic.ttf similarity index 100% rename from src/ui/DMSans-MediumItalic.ttf rename to assets/fonts/DMSans-MediumItalic.ttf diff --git a/src/ui/DMSans-Regular.ttf b/assets/fonts/DMSans-Regular.ttf similarity index 100% rename from src/ui/DMSans-Regular.ttf rename to assets/fonts/DMSans-Regular.ttf diff --git a/src/ui/Widgets/Icon/svgs/arrow-clockwise.svg b/assets/svgs/arrow-clockwise.svg similarity index 100% rename from src/ui/Widgets/Icon/svgs/arrow-clockwise.svg rename to assets/svgs/arrow-clockwise.svg diff --git a/src/ui/Widgets/Icon/svgs/list.svg b/assets/svgs/list.svg similarity index 100% rename from src/ui/Widgets/Icon/svgs/list.svg rename to assets/svgs/list.svg diff --git a/src/ui/Widgets/Icon/svgs/snowflake.svg b/assets/svgs/snowflake.svg similarity index 100% rename from src/ui/Widgets/Icon/svgs/snowflake.svg rename to assets/svgs/snowflake.svg diff --git a/src/ui/Widgets/Icon/svgs/trash.svg b/assets/svgs/trash.svg similarity index 100% rename from src/ui/Widgets/Icon/svgs/trash.svg rename to assets/svgs/trash.svg diff --git a/src/Beat.ts b/src/Beat.ts index 97ea38b..916ae62 100644 --- a/src/Beat.ts +++ b/src/Beat.ts @@ -2,6 +2,7 @@ import Track, {TrackEvents, TrackInitOptions} from "@/Track"; import {IPublisher, Publisher} from "@/Publisher"; import ISubscriber from "@/Subscriber"; import {greatestCommonDivisor, isPosInt} from "@/utils"; +import Ref from "@/Ref"; type BeatGroupInitOptions = { barCount: number; @@ -13,6 +14,17 @@ type BeatGroupInitOptions = { name?: string, }; +export type BeatSerial = { + tracks: Record[], + barCount: number, + timeSigUp: number, + globalLoopLength: number, + globalIsLooping: boolean, + useAutoBeatLength: boolean, + barSettingsLocked: boolean, + name: string, +}; + export const enum BeatEvents { TrackOrderChanged="be-0", TrackListChanged="be-1", @@ -22,14 +34,17 @@ export const enum BeatEvents { LockingChanged="be-5", GlobalLoopLengthChanged="be-5", GlobalDisplayTypeChanged="be-6", - NameChanged="be-7", + DeepChange="be-7", } -type EventTypeSubscriptions = - | TrackEvents.LoopLengthChanged - | TrackEvents.DisplayTypeChanged - | TrackEvents.WantsRemoval - | TrackEvents.Baked; +const EventTypeSubscriptions = [ + TrackEvents.LoopLengthChanged, + TrackEvents.DisplayTypeChanged, + TrackEvents.WantsRemoval, + TrackEvents.DeepChange, + TrackEvents.Baked, +]; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class Beat implements IPublisher, ISubscriber { private static globalCounter = 0; @@ -41,14 +56,14 @@ export default class Beat implements IPublisher, ISubscriber; constructor(options?: BeatGroupInitOptions) { Beat.globalCounter++; if (options?.name) { - this.name = options.name; + this.name = Ref.new(options.name); } else { - this.name = `Pattern ${Beat.globalCounter}`; + this.name = Ref.new(`Pattern ${Beat.globalCounter}`); } if (options?.tracks) { for (const trackOptions of options.tracks) { @@ -62,6 +77,22 @@ export default class Beat implements IPublisher, ISubscriber newBeat.addTrack(Track.deserialise(trackSerial))); + return newBeat; + } + notify(publisher: unknown, event: EventTypeSubscriptions): void { switch (event) { case TrackEvents.LoopLengthChanged: @@ -75,9 +106,10 @@ export default class Beat implements IPublisher, ISubscriber, eventType: BeatEvents | BeatEvents[]): { unbind: () => void } { + addSubscriber(subscriber: ISubscriber, eventType: BeatEvents | Readonly): { unbind: () => void } { return this.publisher.addSubscriber(subscriber, eventType); } @@ -220,25 +252,27 @@ export default class Beat implements IPublisher, ISubscriber, ISubscriber { return this.name; } + + serialise(): Readonly { + return { + tracks: this.tracks.map(track => track.serialise()), + barCount: this.barCount, + timeSigUp: this.timeSigUp, + globalLoopLength: this.globalLoopLength, + globalIsLooping: this.globalIsLooping, + useAutoBeatLength: this.useAutoBeatLength, + barSettingsLocked: this.barSettingsLocked, + name: this.name.val, + } as const; + } + + static isBeatSerial(serial: any): serial is BeatSerial { + return Array.isArray(serial.tracks) && + typeof serial.barCount === "number" && + typeof serial.timeSigUp === "number" && + typeof serial.globalLoopLength === "number" && + typeof serial.globalIsLooping === "boolean" && + typeof serial.useAutoBeatLength === "boolean" && + typeof serial.barSettingsLocked === "boolean"; + } } diff --git a/src/BeatStore.ts b/src/BeatStore.ts new file mode 100644 index 0000000..03cc915 --- /dev/null +++ b/src/BeatStore.ts @@ -0,0 +1,124 @@ +import Beat, {BeatEvents} from "@/Beat"; +import Ref from "@/Ref"; +import ISubscriber from "@/Subscriber"; + +const EventTypeSubscriptions = [ + BeatEvents.TimeSigUpChanged, + BeatEvents.BarCountChanged, + BeatEvents.GlobalDisplayTypeChanged, + BeatEvents.TrackListChanged, + BeatEvents.LockingChanged, + BeatEvents.AutoBeatSettingsChanged, + BeatEvents.DeepChange, +] as const; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; + +export default class BeatStore implements ISubscriber { + private readonly beats: Beat[]; + private activeBeat: Ref; + private onBeatChangeCbs: (() => void)[] = []; + private autoSave: boolean; + + constructor(options: { loadFromLocalStorage: boolean, autoSave: boolean }) { + this.autoSave = options.autoSave; + if (options.loadFromLocalStorage) { + const save = localStorage.getItem("drum-slayer-save"); + if (save) { + const serial = JSON.parse(save); + this.beats = [BeatStore.defaultMainBeatGroup()]; + this.activeBeat = Ref.new(this.beats[0]); + this.loadFromSave(serial); + if (this.autoSave) { + this.activeBeat.watch(() => this.save("localStorage"), true); + this.beats.forEach(beat => beat.addSubscriber(this, EventTypeSubscriptions)); + } + return; + } + } + this.beats = [ + BeatStore.defaultMainBeatGroup(), + ]; + this.activeBeat = Ref.new(this.beats[0]); + } + + notify(publisher: unknown, event: EventTypeSubscriptions): void { + this.save("localStorage"); + } + + static defaultMainBeatGroup(): Beat { + const defaultSettings = { + barCount: 2, + isLooping: false, + timeSigUp: 8, + }; + const mainBeatGroup = new Beat(defaultSettings); + mainBeatGroup.addTrack({name: "LF"}); + mainBeatGroup.addTrack({name: "LH"}); + mainBeatGroup.addTrack({name: "RH"}); + mainBeatGroup.addTrack({name: "RF"}); + return mainBeatGroup; + } + + getActiveBeat(): Ref { + return this.activeBeat; + } + + resetActiveBeat(): void { + const index = this.beats.indexOf(this.activeBeat.val); + const reset = BeatStore.defaultMainBeatGroup(); + this.beats[index] = reset; + this.activeBeat.val = reset; + } + + getBeats(): Beat[] { + return this.beats.slice(); + } + + setActiveBeat(beat: Beat): void { + const index = this.beats.indexOf(beat); + if (index !== -1) { + this.activeBeat.val = this.beats[index]; + } + } + + addNewBeat(): void { + const newBeat = BeatStore.defaultMainBeatGroup(); + this.beats.push(newBeat); + if (this.autoSave) { + newBeat.addSubscriber(this, EventTypeSubscriptions); + } + this.onBeatChangeCbs.forEach(cb => cb()); + if (this.autoSave) { + this.save("localStorage"); + } + } + + onBeatChanges(callback: () => void) { + this.onBeatChangeCbs.push(callback); + } + + save(destination: "localStorage"): void { + if (destination === "localStorage") { + const serials = this.beats.map(beat => beat.serialise()); + localStorage.setItem("drum-slayer-save", JSON.stringify({ + beats: serials, + activeBeatIndex: this.beats.indexOf(this.activeBeat.val), + })); + } + } + + loadFromSave(source: any): void { + this.beats.length = 0; + if (Array.isArray(source.beats) + && (typeof source.activeBeatIndex === "number" || typeof source.activeBeatIndex === "undefined")) { + try { + source.beats.forEach((beat: any) => this.beats.push(Beat.deserialise(beat))); + if (typeof source.activeBeatIndex === "number") { + this.activeBeat.val = this.beats[source.activeBeatIndex]; + } + } catch (err) { + console.error(err); + } + } + } +} \ No newline at end of file diff --git a/src/Publisher.ts b/src/Publisher.ts index 8ccb347..f5412b0 100644 --- a/src/Publisher.ts +++ b/src/Publisher.ts @@ -42,12 +42,12 @@ export class Publisher implements IPubl this.subscribers = new Map(); } - addSubscriber(subscriber: ISubscriber, subscribeTo: EventType | EventType[]): ISubscription { + addSubscriber(subscriber: ISubscriber, subscribeTo: EventType | Readonly): ISubscription { let eventTypes: EventType[] = []; - if (!Array.isArray(subscribeTo)) { + if (typeof subscribeTo === "string") { eventTypes.push(subscribeTo); } else { - eventTypes = subscribeTo; + eventTypes = subscribeTo.slice(); } for (const key of eventTypes) { this.getSubscribers(key).push(subscriber); diff --git a/src/Ref.ts b/src/Ref.ts index 42af00a..27ea57e 100644 --- a/src/Ref.ts +++ b/src/Ref.ts @@ -22,6 +22,7 @@ type AllowedRef = { toString(): string } | string | null; export default class Ref { private watchers: Array<(newVal: T) => void> | null = null; + private afterWatchers: Array<(newVal: T) => void> | null = null; private value: T; private asString?: string; private isString: boolean; @@ -39,21 +40,38 @@ export default class Ref { } } - watch(watcher: (newVal: T) => void): ISubscription { - if (this.watchers === null) { - this.watchers = []; + watch(watcher: (newVal: T) => void, after?: boolean): ISubscription { + if (after) { + if (this.afterWatchers === null) { + this.afterWatchers = []; + } + this.afterWatchers.push(watcher); + } else { + if (this.watchers === null) { + this.watchers = []; + } + this.watchers.push(watcher); } - this.watchers.push(watcher); - return new RefSubscription(() => this.unbind(watcher)); + return new RefSubscription(() => this.unbind(watcher, !!after)); } - private unbind(watcher: (newVal: T) => void): void { - if (!this.watchers) { - return; - } - const index = this.watchers.indexOf(watcher); - if (index !== -1) { - this.watchers.splice(index, 1); + private unbind(watcher: (newVal: T) => void, after: boolean): void { + if (after) { + if (!this.afterWatchers) { + return; + } + const index = this.afterWatchers.indexOf(watcher); + if (index !== -1) { + this.afterWatchers.splice(index, 1); + } + } else { + if (!this.watchers) { + return; + } + const index = this.watchers.indexOf(watcher); + if (index !== -1) { + this.watchers.splice(index, 1); + } } } @@ -64,6 +82,7 @@ export default class Ref { set val(val: T) { this.watchers?.forEach(watcher => watcher(val)); this.value = val; + this.afterWatchers?.forEach(watcher => watcher(val)); } toString(): string { diff --git a/src/Store.ts b/src/Store.ts deleted file mode 100644 index b957eff..0000000 --- a/src/Store.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 index 17c17b7..14d6f39 100644 --- a/src/Track.ts +++ b/src/Track.ts @@ -1,4 +1,4 @@ -import TrackUnit from "@/TrackUnit"; +import TrackUnit, {TrackUnitType} from "@/TrackUnit"; import {IPublisher, Publisher} from "@/Publisher"; import ISubscriber from "@/Subscriber"; import {isPosInt} from "@/utils"; @@ -22,6 +22,20 @@ export const enum TrackEvents { LoopLengthChanged="be-4", WantsRemoval="be-5", Baked="be-6", + DeepChange="be-7", +} + +export type TrackSerial = { + name: string, + timeSigUp: number, + timeSigDown: number, + units: { + isOn: boolean[], + type: TrackUnitType[], + }, + barCount: number, + loopLength: number, + looping: boolean, } export default class Track implements IPublisher { @@ -46,6 +60,30 @@ export default class Track implements IPublisher { this.looping = options?.isLooping ?? false; } + static deserialise(serial: any): Track { + if (!Track.isTrackSerial(serial)) { + throw new Error("Invalid track serial."); + } + const track = new Track({ + bars: serial.barCount, + isLooping: serial.looping, + loopLength: serial.loopLength, + name: serial.name, + timeSig: { + up: serial.timeSigUp, + down: serial.timeSigDown, + }, + }); + const units = serial.units.isOn.map((isOn, i) => new TrackUnit({ + on: isOn, + type: serial.units.type[i], + parent: track, + })); + track.unitRecord.length = 0; + track.unitRecord.push(...units); + return track; + } + setLoopLength(loopLength: number): void { if (!isPosInt(loopLength) || loopLength < 2) { loopLength = this.loopLength; @@ -105,7 +143,9 @@ export default class Track implements IPublisher { } else if (newBarCount > this.unitRecord.length) { const barsToAdd = newBarCount - this.unitRecord.length; for (let i = 0; i < barsToAdd; i++) { - this.unitRecord.push(new TrackUnit()); + this.unitRecord.push(new TrackUnit({ + parent: this, + })); } } } @@ -165,4 +205,36 @@ export default class Track implements IPublisher { this.publisher.notifySubs(TrackEvents.Baked); } } + + serialise(): Readonly { + return { + name: this.name, + timeSigUp: this.timeSigUp, + timeSigDown: this.timeSigDown, + units: { + isOn: this.unitRecord.map(unit => unit.isOn()), + type: this.unitRecord.map(unit => unit.getType()), + }, + barCount: this.barCount, + loopLength: this.loopLength, + looping: this.looping, + } as const; + } + + static isTrackSerial(serial: any): serial is TrackSerial { + const correctTypes = typeof serial.name === "string" && + typeof serial.timeSigUp === "number" && + typeof serial.timeSigDown === "number" && + typeof serial.units === "object" && + Array.isArray(serial.units.isOn) && + Array.isArray(serial.units.type) && + typeof serial.barCount === "number" && + typeof serial.loopLength === "number" && + typeof serial.looping === "boolean"; + return correctTypes && serial.units.isOn.length === serial.units.type.length; + } + + alertDeepChange(): void { + this.publisher.notifySubs(TrackEvents.DeepChange); + } } \ No newline at end of file diff --git a/src/TrackUnit.ts b/src/TrackUnit.ts index e913dde..4d027ce 100644 --- a/src/TrackUnit.ts +++ b/src/TrackUnit.ts @@ -1,5 +1,6 @@ import {IPublisher, Publisher} from "./Publisher"; import ISubscriber from "./Subscriber"; +import Track from "@/Track"; export const enum TrackUnitType { Normal="tut-0", @@ -26,10 +27,16 @@ export default class TrackUnit implements IPublisher { private publisher: Publisher = new Publisher(this); private on = false; private typeIndex = 0; + private parent: Track; - constructor(on = false, type = TrackUnitType.Normal) { - this.on = on; - this.setType(type); + constructor(options: { + on?: boolean, + type?: TrackUnitType, + parent: Track, + }) { + this.parent = options.parent; + this.on = options.on ?? false; + this.setType(options.type ?? TrackUnitType.Normal); } addSubscriber(subscriber: ISubscriber, eventType: TrackUnitEvent[]): { unbind: () => void } { @@ -44,16 +51,19 @@ export default class TrackUnit implements IPublisher { } else { this.publisher.notifySubs(TrackUnitEvent.Off); } + this.parent.alertDeepChange(); } setOn(on: boolean): void { this.on = on; this.publisher.notifySubs(this.on ? TrackUnitEvent.On : TrackUnitEvent.Off); + this.parent.alertDeepChange(); } setType(type: TrackUnitType): void { this.typeIndex = TrackUnit.TypeRotation.indexOf(type); this.publisher.notifySubs(TrackUnitEvent.TypeChange); + this.parent.alertDeepChange(); } getType(): TrackUnitType { @@ -67,6 +77,7 @@ export default class TrackUnit implements IPublisher { this.typeIndex += 1; } this.publisher.notifySubs(TrackUnitEvent.TypeChange); + this.parent.alertDeepChange(); } isOn(): boolean { diff --git a/src/ui/Beat/Beat.css b/src/ui/Beat/Beat.css index 5d9f19c..2f7fecc 100644 --- a/src/ui/Beat/Beat.css +++ b/src/ui/Beat/Beat.css @@ -7,9 +7,28 @@ flex-direction: column; } +.vertical-mode .beat { + align-items: center; +} + .vertical-mode .beat { height: inherit; overflow-x: hidden; overflow-y: scroll; - display: block; } + +.beat-title { + color: var(--color-title-light); + text-align: center; + width: fit-content; + padding-left: 16px; +} + +.vertical-mode .beat-title { + color: var(--color-title-light); + text-align: center; + padding-left: 0; +} + +.beat-track-container { +} \ No newline at end of file diff --git a/src/ui/Beat/BeatView.ts b/src/ui/Beat/BeatView.ts index 3f27e24..ab4757a 100644 --- a/src/ui/Beat/BeatView.ts +++ b/src/ui/Beat/BeatView.ts @@ -4,21 +4,21 @@ import TrackView from "@/ui/Track/TrackView"; import "./Beat.css"; import ISubscriber from "@/Subscriber"; import {ISubscription} from "@/Publisher"; +import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView"; export type BeatUINodeOptions = UINodeOptions & { - title: string, beat: Beat, orientation?: "horizontal" | "vertical", }; const EventTypeSubscriptions = [ - BeatEvents.TrackListChanged -]; -type EventTypeSubscriptions = FlatArray; + BeatEvents.TrackListChanged, +] as const; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class BeatView extends UINode implements ISubscriber { - private title: string; private beat: Beat; + private title: EditableTextFieldView; private trackViews: TrackView[] = []; private currentOrientation: "vertical" | "horizontal"; private subscription: ISubscription; @@ -26,9 +26,13 @@ export default class BeatView extends UINode implements ISubscriber this.beat.setName(text), + noEmpty: true, + initialText: this.beat.getName().val, + }); this.setupTrackViews(); } @@ -69,19 +73,37 @@ export default class BeatView extends UINode implements ISubscriber { + this.title.setText(newVal); + }); + this.title.setText(this.beat.getName().val); + EventTypeSubscriptions.forEach(event => this.notify(this, event)); this.setupTrackViews(); this.redraw(); } + setBeat(newBeat: Beat): void { + this.beat = newBeat; + this.subscription.unbind(); + this.subscription = this.beat.addSubscriber(this, BeatEvents.TrackListChanged); + this.onNewBeat(); + } + build(): HTMLDivElement { return h("div", { - classes: ["beat"], + className: "beat", },[ - ...this.trackViews + h("h2", { + className: "beat-title", + }, [ + this.title, + ]), + h("div", { + className: "beat-track-container", + }, [ + ...this.trackViews, + ]), ]); } } \ No newline at end of file diff --git a/src/ui/BeatSettings/BeatSettingsView.ts b/src/ui/BeatSettings/BeatSettingsView.ts index f56b0d9..9b9e3ff 100644 --- a/src/ui/BeatSettings/BeatSettingsView.ts +++ b/src/ui/BeatSettings/BeatSettingsView.ts @@ -19,7 +19,7 @@ const EventTypeSubscriptions = [ BeatEvents.LockingChanged, BeatEvents.AutoBeatSettingsChanged, ]; -type EventTypeSubscriptions = FlatArray; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class BeatSettingsView extends UINode implements ISubscriber { private beat: Beat; diff --git a/src/ui/Root/Root.css b/src/ui/Root/Root.css index abf4ddd..8d6191a 100644 --- a/src/ui/Root/Root.css +++ b/src/ui/Root/Root.css @@ -9,6 +9,7 @@ --color-ui-neutral-dark-hover: #a1a1a1; --color-ui-neutral-dark-active: #c1c1c1; --color-bg-light: #464646; + --color-bg-medium: #323232; --color-bg-dark: #282828; --color-p-light: #fafafa; --color-p-light-hover: #fafafa; @@ -45,7 +46,6 @@ .root-settings { z-index: 1; width: 28em; - padding: 0 0 0 2em; background-color: var(--color-bg-light); overflow: scroll; display: inline-block; @@ -97,7 +97,7 @@ } .vertical-mode .root-beat-stage { - margin: 5em auto auto; + margin: auto auto; padding-left: 3em; height: 100vh; } @@ -106,6 +106,39 @@ max-width: calc(100vw - 30em); } +.root-sidebar-left-strip { + text-align: right; + writing-mode: sideways-lr; + background-color: var(--color-bg-light); +} + +.root-sidebar-left-strip > * { + display: inline-block; +} + +.root-sidebar-left-tab { + display: inline-block; + width: 100%; + padding: 8px 3px 8px 3px; +} + +.root-sidebar-left-tab.active { + background-color: var(--color-bg-medium); + display: inline-block; +} + +.root-sidebar-add-beat { + width: 100%; + padding: 8px 3px 8px 3px; +} + +.root-sidebar-add-beat:hover, +.root-sidebar-left-tab:hover:not(.active) { + cursor: pointer; + background-color: var(--color-ui-neutral-dark); + transition: background-color 200ms; +} + @media screen and (max-width: 900px) { .sidebar-visible .root-sidebar { left: 0; diff --git a/src/ui/Root/RootView.ts b/src/ui/Root/RootView.ts index f2e28d4..1438df6 100644 --- a/src/ui/Root/RootView.ts +++ b/src/ui/Root/RootView.ts @@ -1,11 +1,11 @@ -import UINode, {h, UINodeOptions} from "@/ui/UINode"; +import UINode, {h, q, UINodeOptions} from "@/ui/UINode"; import BeatView from "@/ui/Beat/BeatView"; import Beat from "@/Beat"; import "./Root.css"; import BeatSettingsView from "@/ui/BeatSettings/BeatSettingsView"; import IconView from "@/ui/Widgets/Icon/IconView"; -import StageTitleBarView from "@/ui/StageTitleBar/StageTitleBarView"; import Ref from "@/Ref"; +import BeatStore from "@/BeatStore"; export type RootUINodeOptions = UINodeOptions & { title: string, @@ -16,24 +16,34 @@ export type RootUINodeOptions = UINodeOptions & { export default class RootView extends UINode { private title: string; private beatView: BeatView; - private focusedBeat: Beat; + private beatStore: BeatStore; + private activeBeat: Ref; private beatSettingsView: BeatSettingsView; private currentOrientation: "horizontal" | "vertical"; - private stageTitleBarView: StageTitleBarView; private showHideSidebarButton: Ref = Ref.new(null); private sidebarActive = true; + private sidebarLeftTabs: Ref = Ref.new(null); constructor(options: RootUINodeOptions) { super(options); + this.beatStore = new BeatStore({ + loadFromLocalStorage: true, + autoSave: true, + }); this.currentOrientation = options.orientation ?? "horizontal"; - this.focusedBeat = options.mainBeat ?? RootView.defaultMainBeatGroup(); + this.activeBeat = this.beatStore.getActiveBeat(); + this.activeBeat.watch((newVal) => { + this.beatSettingsView.setBeat(newVal); + this.beatView.setBeat(newVal); + }); this.beatView = new BeatView({ - title: options.title, - beat: this.focusedBeat, + beat: this.activeBeat.val, orientation: this.currentOrientation, }); - this.stageTitleBarView = new StageTitleBarView({beat: this.focusedBeat}); - this.beatSettingsView = new BeatSettingsView({beat: this.focusedBeat}); + this.beatStore.onBeatChanges(() => { + this.sidebarLeftTabs.val?.replaceChildren(...this.buildTabs()); + }); + this.beatSettingsView = new BeatSettingsView({beat: this.activeBeat.val}); this.title = options.title; this.setOrientation(this.currentOrientation); this.openSidebarForDesktop(); @@ -46,27 +56,6 @@ export default class RootView extends UINode { } } - static defaultMainBeatGroup(): Beat { - const defaultSettings = { - barCount: 2, - isLooping: false, - timeSigUp: 8, - }; - const mainBeatGroup = new Beat(defaultSettings); - mainBeatGroup.addTrack({name: "LF"}); - mainBeatGroup.addTrack({name: "LH"}); - mainBeatGroup.addTrack({name: "RH"}); - mainBeatGroup.addTrack({name: "RF"}); - return mainBeatGroup; - } - - setMainBeatGroup(beat: Beat): void { - this.focusedBeat = beat; - this.beatSettingsView.setBeat(this.focusedBeat); - this.beatView.setBeat(this.focusedBeat); - this.stageTitleBarView.setBeat(this.focusedBeat); - } - toggleSidebar(): void { this.sidebarActive = !this.sidebarActive; this.showHideSidebarButton.val!.title = this.sidebarText(); @@ -95,8 +84,40 @@ export default class RootView extends UINode { return `${this.sidebarActive ? "Hide" : "Show"} sidebar`; } + private buildSidebarStripLeft(): HTMLElement { + return h("div", { + className: "root-sidebar-left-strip", + }, [ + h("div", { + className: "root-sidebar-add-beat", + onclick: () => this.beatStore.addNewBeat(), + innerText: "+", + }), + h("div", { + saveTo: this.sidebarLeftTabs + }, this.buildTabs()), + ]); + } - private buildSidebarStrip(): HTMLElement { + private buildTabs(): HTMLElement[] { + return this.beatStore.getBeats().map((beat) => { + const node = h("div", { + className: "root-sidebar-left-tab" + (beat === this.activeBeat.val ? " active" : ""), + onclick: () => this.beatStore.setActiveBeat(beat), + innerText: beat.getName(), + }); + this.activeBeat.watch((newVal) => { + if (beat === newVal) { + node.classList.add("active"); + } else { + node.classList.remove("active"); + } + }); + return node; + }).reverse(); + } + + private buildSidebarQuickButtons(): HTMLElement { return h("div", { classes: ["root-sidebar-toggle"], }, [ @@ -124,7 +145,7 @@ export default class RootView extends UINode { h("div", { classes: ["root-quick-access-button"], title: "Bake all tracks", - onclick: () => this.focusedBeat.bakeLoops(), + onclick: () => this.activeBeat.val.bakeLoops(), }, [ new IconView({ iconName: "snowflake", @@ -134,7 +155,7 @@ export default class RootView extends UINode { h("div", { classes: ["root-quick-access-button"], title: "Reset all", - onclick: () => this.setMainBeatGroup(RootView.defaultMainBeatGroup()), + onclick: () => this.beatStore.resetActiveBeat(), }, [ new IconView({ iconName: "trash", @@ -147,11 +168,12 @@ export default class RootView extends UINode { private buildSidebar(): HTMLElement { return ( h("div", {classes: ["root-sidebar"]}, [ + this.buildSidebarStripLeft(), h("div", {classes: ["root-settings"]}, [ h("h1", {classes: ["root-title"], innerText: this.title}), this.beatSettingsView, ]), - this.buildSidebarStrip(), + this.buildSidebarQuickButtons(), ]) ); } @@ -161,7 +183,6 @@ export default class RootView extends UINode { h("div", {classes: ["root", "sidebar-visible"]}, [ this.buildSidebar(), h("div", {classes: ["root-beat-stage-container"]}, [ - this.stageTitleBarView, h("div", {classes: ["root-beat-stage"]}, [ this.beatView, ]) diff --git a/src/ui/StageTitleBar/StageTitleBar.css b/src/ui/StageTitleBar/StageTitleBar.css deleted file mode 100644 index 71ce3d3..0000000 --- a/src/ui/StageTitleBar/StageTitleBar.css +++ /dev/null @@ -1,20 +0,0 @@ -.stage-title-bar { - position: absolute; - background-color: var(--color-bg-light); - padding: 15px; - border-radius: 0 0 5px 5px; - color: var(--color-title-light); - left: 50%; - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; -} - -.stage-title-bar * { - flex: 1; -} - -.stage-title-bar h2 { - margin: 0; -} \ No newline at end of file diff --git a/src/ui/StageTitleBar/StageTitleBarView.ts b/src/ui/StageTitleBar/StageTitleBarView.ts deleted file mode 100644 index f4ed55a..0000000 --- a/src/ui/StageTitleBar/StageTitleBarView.ts +++ /dev/null @@ -1,54 +0,0 @@ -import "./StageTitleBar.css"; -import UINode, {h, UINodeOptions} from "@/ui/UINode"; -import {ISubscription} from "@/Publisher"; -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 & { - beat: Beat, -}; - -const EventTypeSubscription = [BeatEvents.NameChanged]; -type EventTypeSubscription = FlatArray; - -export default class StageTitleBarView extends UINode implements ISubscriber { - private sub: ISubscription; - private beat: Beat; - private title: EditableTextFieldView; - private options: Ref; - - constructor(options: StageTitleBarViewOptions) { - super(options); - this.beat = options.beat; - this.sub = options.beat.addSubscriber(this, EventTypeSubscription); - this.title = new EditableTextFieldView({ - initialText: this.beat.getName(), - setter: (text) => this.beat.setName(text), - noEmpty: true, - }); - this.options = Ref.new([]); - } - - notify(publisher: unknown, event: EventTypeSubscription): void { - if (event === BeatEvents.NameChanged) { - this.title.setText(this.beat.getName()); - } - } - - setBeat(beat: Beat): void { - this.sub.unbind(); - 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("h2", {}, [this.title]), - new DropdownView({options: this.options}) - ]); - } -} diff --git a/src/ui/Track/TrackView.ts b/src/ui/Track/TrackView.ts index c159f9c..3bb0c67 100644 --- a/src/ui/Track/TrackView.ts +++ b/src/ui/Track/TrackView.ts @@ -18,7 +18,7 @@ const EventTypeSubscriptions = [ TrackEvents.LoopLengthChanged, ]; -type EventTypeSubscriptions = FlatArray; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class TrackView extends UINode implements ISubscriber { private track!: Track; diff --git a/src/ui/TrackSettings/TrackSettingsView.ts b/src/ui/TrackSettings/TrackSettingsView.ts index a79cc7f..530649e 100644 --- a/src/ui/TrackSettings/TrackSettingsView.ts +++ b/src/ui/TrackSettings/TrackSettingsView.ts @@ -17,7 +17,7 @@ const EventTypeSubscriptions = [ TrackEvents.LoopLengthChanged, TrackEvents.DisplayTypeChanged, ]; -type EventTypeSubscriptions = FlatArray; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class TrackSettingsView extends UINode implements ISubscriber { private track: Track; diff --git a/src/ui/TrackUnit/TrackUnitView.ts b/src/ui/TrackUnit/TrackUnitView.ts index a441978..ecce1f3 100644 --- a/src/ui/TrackUnit/TrackUnitView.ts +++ b/src/ui/TrackUnit/TrackUnitView.ts @@ -13,7 +13,7 @@ const EventTypeSubscriptions = [ TrackUnitEvent.Off, TrackUnitEvent.TypeChange, ]; -type EventTypeSubscriptions = FlatArray; +type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; export default class TrackUnitView extends UINode implements ISubscriber { private trackUnit: TrackUnit; diff --git a/src/ui/Widgets/Icon/IconView.ts b/src/ui/Widgets/Icon/IconView.ts index 0e9395b..ce8fe84 100644 --- a/src/ui/Widgets/Icon/IconView.ts +++ b/src/ui/Widgets/Icon/IconView.ts @@ -1,10 +1,9 @@ import UINode, {h, UINodeOptions} from "@/ui/UINode"; import "./Icon.css"; -import List from "./svgs/list.svg"; -import ArrowClockwise from "./svgs/arrow-clockwise.svg"; -import Trash from "./svgs/trash.svg"; -import Snowflake from "./svgs/snowflake.svg"; -import Ref from "@/Ref"; +import List from "assets/svgs/list.svg"; +import ArrowClockwise from "assets/svgs/arrow-clockwise.svg"; +import Trash from "assets/svgs/trash.svg"; +import Snowflake from "assets/svgs/snowflake.svg"; const IconUrlMap = { arrowClockwise: ArrowClockwise, diff --git a/src/ui/global.css b/src/ui/global.css index 3ef36e6..f130bd0 100644 --- a/src/ui/global.css +++ b/src/ui/global.css @@ -76,26 +76,26 @@ body { font-family: 'DMSans'; font-style: normal; font-weight: 400; - src: url(./DMSans-Regular.ttf) format('woff2'); + src: url(assets/fonts/DMSans-Regular.ttf) format('woff2'); } @font-face { font-family: 'DMSans'; font-style: normal; font-weight: 600; - src: url(./DMSans-Bold.ttf) format('woff2'); + src: url(assets/fonts/DMSans-Bold.ttf) format('woff2'); } @font-face { font-family: 'DMSans'; font-style: italic; font-weight: 400; - src: url(./DMSans-Italic.ttf) format('woff2'); + src: url(assets/fonts/DMSans-Italic.ttf) format('woff2'); } @font-face { font-family: 'DMSans'; font-style: italic; font-weight: 600; - src: url(./DMSans-BoldItalic.ttf) format('woff2'); + src: url(assets/fonts/DMSans-BoldItalic.ttf) format('woff2'); } diff --git a/tsconfig.json b/tsconfig.json index de319fe..b7a2219 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,8 +10,12 @@ "resolveJsonModule": true, "baseUrl": "./", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "assets/*": ["assets/*"] } }, - "include": ["./src/**/*"] + "include": [ + "./src/**/*", + "./assets/**/*" + ] } diff --git a/webpack.config.js b/webpack.config.js index e40e320..1831a51 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -44,8 +44,9 @@ const webpackConfig = { resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "assets": path.resolve(__dirname, "./assets"), }, - extensions: [".tsx", ".ts", ".js"] + extensions: [".tsx", ".ts", ".js", ".svg", ".ttf"] }, output: {