diff --git a/src/BeatGroup.ts b/src/BeatGroup.ts index 7b3f8b2..e7402df 100644 --- a/src/BeatGroup.ts +++ b/src/BeatGroup.ts @@ -1,7 +1,6 @@ import Beat, {BeatEvents, BeatInitOptions} from "@/Beat"; import {IPublisher, Publisher} from "@/Publisher"; import ISubscriber from "@/Subscriber"; -import BeatLike from "@/BeatLike"; import {greatestCommonDivisor, isPosInt} from "@/utils"; type BeatGroupInitOptions = { @@ -11,6 +10,7 @@ type BeatGroupInitOptions = { beats?: BeatInitOptions[], loopLength?: number, useAutoBeatLength?: boolean, + name?: string, }; export const enum BeatGroupEvents { @@ -20,6 +20,9 @@ export const enum BeatGroupEvents { TimeSigUpChanged="bge-3", AutoBeatSettingsChanged="bge-4", LockingChanged="bge-5", + GlobalLoopLengthChanged="bge-5", + GlobalDisplayTypeChanged="bge-6", + NameChanged="bge-7", } type EventTypeSubscriptions = @@ -28,19 +31,25 @@ type EventTypeSubscriptions = | BeatEvents.WantsRemoval | BeatEvents.Baked; -type EventTypePublications = BeatGroupEvents | BeatEvents; - -export default class BeatGroup implements IPublisher, BeatLike, ISubscriber { +export default class BeatGroup implements IPublisher, ISubscriber { + private static globalCounter = 0; private beats: Beat[] = []; - private publisher: Publisher = new Publisher(this); + 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); @@ -68,7 +77,7 @@ export default class BeatGroup implements IPublisher, Bea } } - addSubscriber(subscriber: ISubscriber, eventType: SubscriptionEvent): { unbind: () => void } { + addSubscriber(subscriber: ISubscriber, eventType: BeatGroupEvents | BeatGroupEvents[]): { unbind: () => void } { return this.publisher.addSubscriber(subscriber, eventType); } @@ -103,7 +112,7 @@ export default class BeatGroup implements IPublisher, Bea for (const beat of this.beats) { beat.setLoopLength(loopLength); } - this.publisher.notifySubs(BeatEvents.LoopLengthChanged); + this.publisher.notifySubs(BeatGroupEvents.GlobalLoopLengthChanged); } getLoopLength(): number { @@ -115,7 +124,7 @@ export default class BeatGroup implements IPublisher, Bea for (const beat of this.beats) { beat.setLooping(isLooping); } - this.publisher.notifySubs(BeatEvents.DisplayTypeChanged); + this.publisher.notifySubs(BeatGroupEvents.GlobalDisplayTypeChanged); } isLooping(): boolean { @@ -238,6 +247,7 @@ export default class BeatGroup implements IPublisher, Bea const beat = this.getBeatByKey(beatKey); this.beats.splice(this.beats.indexOf(beat), 1); this.autoBeatLength(); + console.log("removing"); this.publisher.notifySubs(BeatGroupEvents.BeatListChanged); } @@ -284,4 +294,13 @@ export default class BeatGroup implements IPublisher, Bea 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/main.ts b/src/main.ts index 72db28d..6a7dcfc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ const appNode = document.querySelector("#app"); if (appNode) { try { const appRoot = new RootView({ + orientation: "vertical", title: "Drum Slayer", }); // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/src/ui/BeatGroup/BeatGroupView.ts b/src/ui/BeatGroup/BeatGroupView.ts index c987196..4ccb4c2 100644 --- a/src/ui/BeatGroup/BeatGroupView.ts +++ b/src/ui/BeatGroup/BeatGroupView.ts @@ -3,10 +3,12 @@ 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 = [ @@ -18,31 +20,56 @@ export default class BeatGroupView extends UINode implements ISubscriber; -export default class BeatGroupSettingsView extends UINode implements ISubscriber { +export default class BeatGroupSettingsView extends UINode implements ISubscriber { private beatGroup: BeatGroup; private barCountInput!: NumberInputView; private timeSigUpInput!: NumberInputView; @@ -45,7 +45,7 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber this.beatGroup.addSubscriber(this, EventTypeSubscriptions); } - notify(publisher: unknown, event: SubscriptionEvent): void { + notify(publisher: unknown, event: EventTypeSubscriptions): void { switch(event) { case BeatGroupEvents.BarCountChanged: this.barCountInput.setValue(this.beatGroup.getBarCount()); @@ -66,7 +66,7 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber case BeatGroupEvents.AutoBeatSettingsChanged: this.autoBeatLengthCheckbox.setValue(this.beatGroup.autoBeatLengthOn()); break; - case BeatEvents.DisplayTypeChanged: + case BeatGroupEvents.GlobalDisplayTypeChanged: break; } } @@ -82,9 +82,7 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber } } if (!this.beatSettingsContainer) { - this.beatSettingsContainer = UINode.make("div", { - subs: this.beatSettingsViews.map(view => view.render()) - }); + this.beatSettingsContainer = UINode.make("div", {}, this.beatSettingsViews.map(view => view.render())); } else { this.beatSettingsContainer.replaceChildren(...this.beatSettingsViews.map(view => view.render())); } @@ -118,18 +116,18 @@ export default class BeatGroupSettingsView extends UINode implements ISubscriber UINode.make("div", { classes: ["beat-group-settings-boxes", "beat-group-settings-option"], }, [ - this.timeSigUpInput.render(), + this.timeSigUpInput, ]), UINode.make("div", { classes: ["beat-group-settings-bar-count", "beat-group-settings-option"] , }, [ - this.barCountInput.render(), + this.barCountInput, ]), UINode.make("div", { classes: ["beat-group-settings-bar-count", "beat-group-settings-option"], }, [ - this.autoBeatLengthCheckbox.render(), + this.autoBeatLengthCheckbox, ]), new ActionButtonView({ label: "New Track", diff --git a/src/ui/BeatSettings/BeatSettings.css b/src/ui/BeatSettings/BeatSettings.css index f3124a0..9544bc7 100644 --- a/src/ui/BeatSettings/BeatSettings.css +++ b/src/ui/BeatSettings/BeatSettings.css @@ -2,12 +2,16 @@ } -.beat-settings-title-input { - width: 100%; +.beat-settings-title-container { + +} + +.beat-settings-title-container input { + min-width: 100%; height: 2em; } -.beat-settings-title { +.beat-settings-title-container > div { width: 100%; font-weight: bold; padding: 0.5em; @@ -15,7 +19,7 @@ cursor: pointer; } -.beat-settings-title:hover { +.beat-settings-title-container > div:hover { background-color: var(--color-ui-neutral-dark-hover); } diff --git a/src/ui/BeatSettings/BeatSettingsView.ts b/src/ui/BeatSettings/BeatSettingsView.ts index c32e2a6..0eb59b2 100644 --- a/src/ui/BeatSettings/BeatSettingsView.ts +++ b/src/ui/BeatSettings/BeatSettingsView.ts @@ -1,11 +1,12 @@ import "./BeatSettings.css"; import Beat, {BeatEvents} from "@/Beat"; import UINode, {UINodeOptions} from "@/ui/UINode"; -import ISubscriber, {SubscriptionEvent} from "@/Subscriber"; +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 & { beat: Beat, @@ -16,16 +17,16 @@ const EventTypeSubscriptions = [ BeatEvents.LoopLengthChanged, BeatEvents.DisplayTypeChanged, ]; +type EventTypeSubscriptions = FlatArray; -export default class BeatSettingsView extends UINode implements ISubscriber { +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 titleInput!: HTMLInputElement; - private titleDisplay!: HTMLSpanElement; + private title!: EditableTextFieldView; private editingTitle: boolean; constructor(options: BeatSettingsViewUINodeOptions) { @@ -36,7 +37,7 @@ export default class BeatSettingsView extends UINode implements ISubscriber this.notify(null, eventType)); } - notify(publisher: unknown, event: SubscriptionEvent): void { + notify(publisher: unknown, event: EventTypeSubscriptions): void { switch(event) { case BeatEvents.NewName: - this.titleInput.value = this.beat.getName(); - this.titleDisplay.innerText = this.beat.getName(); + this.title.setText(this.beat.getName()); break; case BeatEvents.LoopLengthChanged: this.loopLengthInput.setValue(this.beat.getLoopLength()); @@ -68,27 +68,9 @@ export default class BeatSettingsView extends UINode implements ISubscriber { - this.beat.setName((event.target as HTMLInputElement).value); - }, - onblur: () => this.titleInput.replaceWith(this.titleDisplay), - onkeyup: (event: KeyboardEvent) => { - if (event.key === "Enter") { - (event.target as HTMLInputElement).blur(); - } - } - }); - this.titleDisplay = UINode.make("div", { - innerText: this.beat.getName(), - classes: ["beat-settings-title"], - onclick: () => { - this.titleDisplay.replaceWith(this.titleInput); - this.titleInput.focus(); - } + this.title = new EditableTextFieldView({ + initialText: this.beat.getName(), + setter: (newText) => this.beat.setName(newText), }); this.bakeButton = new ActionButtonView({ icon: "snowflake", @@ -121,11 +103,15 @@ export default class BeatSettingsView extends UINode implements ISubscriber = new Publisher(this); private touchTimeout: ReturnType | null = null; - + private mouseDownListeners: ((ev: MouseEvent) => void)[] = []; + private hoverListeners: ((ev: MouseEvent) => void)[] = []; constructor(options: BeatUnitUINodeOptions) { super(options); @@ -35,26 +36,37 @@ export default class BeatUnitView extends UINode implements ISubscriber { + this.beatUnit.rotateType(); + this.touchTimeout = null; + }, 400); + } + + private handleTouchEnd(ev: TouchEvent): void { + if (this.touchTimeout) { + clearTimeout(this.touchTimeout); + this.touchTimeout = null; + } + } + private setupBindings() { this.subscription?.unbind(); this.subscription = this.beatUnit.addSubscriber(this, EventTypeSubscriptions); - this.onMouseDown((ev: MouseEvent) => { - if (ev.button === 1) { - this.beatUnit.rotateType(); - } - }); - this.getNode().addEventListener("touchstart", () => { - this.touchTimeout = setTimeout(() => { - this.beatUnit.rotateType(); - this.touchTimeout = null; - }, 400); - }); - this.getNode().addEventListener("touchend", () => { - if (this.touchTimeout) { - clearTimeout(this.touchTimeout); - this.touchTimeout = null; - } - }); + this.mouseDownListeners.forEach(listener => this.getNode().removeEventListener("mousedown", listener)); + this.hoverListeners.forEach(listener => this.getNode().removeEventListener("mouseover", listener)); + this.redraw(); + this.mouseDownListeners.forEach(listener => this.getNode().addEventListener("mousedown", listener)); + this.hoverListeners.forEach(listener => this.getNode().addEventListener("mouseover", listener)); + this.getNode().addEventListener("mousedown", (ev) => this.handleMouseDown(ev)); + this.getNode().addEventListener("touchstart", (ev) => this.handleTouchStart(ev)); + this.getNode().addEventListener("touchend", (ev) => this.handleTouchEnd(ev)); } toggle(): void { @@ -108,14 +120,12 @@ export default class BeatUnitView extends UINode implements ISubscriber void): void { - this.getNode().addEventListener("mouseover", cb); + this.hoverListeners.push(cb); + this.setupBindings(); } onMouseDown(cb: (ev: MouseEvent) => void): void { - this.getNode().addEventListener("mousedown", cb); - } - - onMouseUp(cb: (ev: MouseEvent) => void): void { - this.getNode().addEventListener("mouseup", cb); + this.mouseDownListeners.push(cb); + this.setupBindings(); } } diff --git a/src/ui/Root/Root.css b/src/ui/Root/Root.css index 742bd1b..abf4ddd 100644 --- a/src/ui/Root/Root.css +++ b/src/ui/Root/Root.css @@ -88,6 +88,7 @@ } .root-beat-stage { + position: relative; padding: 2em; max-height: 100vh; margin: auto; @@ -96,6 +97,8 @@ } .vertical-mode .root-beat-stage { + margin: 5em auto auto; + padding-left: 3em; height: 100vh; } diff --git a/src/ui/Root/RootView.ts b/src/ui/Root/RootView.ts index a65b7c5..b51026f 100644 --- a/src/ui/Root/RootView.ts +++ b/src/ui/Root/RootView.ts @@ -4,25 +4,43 @@ import BeatGroup from "@/BeatGroup"; import "./Root.css"; import BeatGroupSettingsView from "@/ui/BeatGroupSettings/BeatGroupSettingsView"; import IconView from "@/ui/Widgets/Icon/IconView"; +import StageTitleBarView from "@/ui/StageTitleBar/StageTitleBarView"; export type RootUINodeOptions = UINodeOptions & { title: string, mainBeatGroup?: BeatGroup, + orientation?: "horizontal" | "vertical", }; export default class RootView extends UINode { private title: string; private beatGroupView: BeatGroupView; - private mainBeatGroup: BeatGroup; + private focusedBeatGroup: BeatGroup; private beatGroupSettingsView!: BeatGroupSettingsView; - + private currentOrientation: "horizontal" | "vertical"; + private stageTitleBarView: StageTitleBarView; constructor(options: RootUINodeOptions) { super(options); - this.mainBeatGroup = options.mainBeatGroup ?? RootView.defaultMainBeatGroup(); - this.beatGroupView = new BeatGroupView({title: options.title, beatGroup: this.mainBeatGroup}); - this.beatGroupSettingsView = new BeatGroupSettingsView({beatGroup: this.mainBeatGroup}); + this.currentOrientation = options.orientation ?? "horizontal"; + this.focusedBeatGroup = options.mainBeatGroup ?? RootView.defaultMainBeatGroup(); + this.beatGroupView = new BeatGroupView({ + title: options.title, + beatGroup: this.focusedBeatGroup, + orientation: this.currentOrientation, + }); + this.stageTitleBarView = new StageTitleBarView({beatGroup: this.focusedBeatGroup}); + this.beatGroupSettingsView = new BeatGroupSettingsView({beatGroup: this.focusedBeatGroup}); this.title = options.title; + this.setOrientation(this.currentOrientation); + this.openSidebarForDesktop(); + } + + private openSidebarForDesktop() { + const mediaQueryList = window.matchMedia("screen and (max-width: 900px)"); + if (mediaQueryList.matches) { + this.toggleSidebar(); + } } static defaultMainBeatGroup(): BeatGroup { @@ -39,12 +57,33 @@ export default class RootView extends UINode { return mainBeatGroup; } + setMainBeatGroup(beatGroup: BeatGroup): void { + this.focusedBeatGroup = beatGroup; + this.beatGroupSettingsView.setBeatGroup(this.focusedBeatGroup); + this.beatGroupView.setBeatGroup(this.focusedBeatGroup); + this.stageTitleBarView.setBeatGroup(this.focusedBeatGroup); + } + toggleSidebar(): void { this.getNode().classList.toggle("sidebar-visible"); } toggleOrientation(): void { - this.getNode().classList.toggle("vertical-mode"); + if (this.currentOrientation === "vertical") { + this.setOrientation("horizontal"); + } else { + this.setOrientation("vertical"); + } + } + + setOrientation(orientation: "horizontal" | "vertical"): void { + this.currentOrientation = orientation; + if (orientation === "vertical") { + this.getNode().classList.add("vertical-mode"); + } else { + this.getNode().classList.remove("vertical-mode"); + } + this.beatGroupView.setOrientation(orientation); } private buildSidebarStrip(): HTMLElement { @@ -71,7 +110,7 @@ export default class RootView extends UINode { ]), UINode.make("div", { classes: ["root-quick-access-button"], - onclick: () => this.mainBeatGroup.bakeLoops(), + onclick: () => this.focusedBeatGroup.bakeLoops(), }, [ new IconView({ iconName: "snowflake", @@ -81,11 +120,7 @@ export default class RootView extends UINode { UINode.make("div", { classes: ["root-quick-access-button"], title: "Reset all", - onclick: () => { - this.mainBeatGroup = RootView.defaultMainBeatGroup(); - this.beatGroupSettingsView.setBeatGroup(this.mainBeatGroup); - this.beatGroupView.setBeatGroup(this.mainBeatGroup); - }, + onclick: () => this.setMainBeatGroup(RootView.defaultMainBeatGroup()), }, [ new IconView({ iconName: "trash", @@ -99,11 +134,10 @@ export default class RootView extends UINode { return ( UINode.make("div", {classes: ["root-sidebar"]}, [ UINode.make("div", {classes: ["root-settings"]}, [ - UINode.make("h1", {classes: ["root-title"], innerText: this.title}, [ - this.beatGroupSettingsView.render(), - ]), - this.buildSidebarStrip(), + UINode.make("h1", {classes: ["root-title"], innerText: this.title}), + this.beatGroupSettingsView.render(), ]), + this.buildSidebarStrip(), ]) ); } @@ -113,6 +147,7 @@ export default class RootView extends UINode { UINode.make("div", {classes: ["root", "sidebar-visible"]}, [ this.buildSidebar(), UINode.make("div", {classes: ["root-beat-stage-container"]}, [ + this.stageTitleBarView.render(), UINode.make("div", {classes: ["root-beat-stage"]}, [ this.beatGroupView.render(), ]) diff --git a/src/ui/StageTitleBar/StageTitleBar.css b/src/ui/StageTitleBar/StageTitleBar.css new file mode 100644 index 0000000..113231c --- /dev/null +++ b/src/ui/StageTitleBar/StageTitleBar.css @@ -0,0 +1,25 @@ +.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-preamble { + margin-bottom: 4px; + font-size: 12px; +} + +.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 new file mode 100644 index 0000000..7874256 --- /dev/null +++ b/src/ui/StageTitleBar/StageTitleBarView.ts @@ -0,0 +1,50 @@ +import "./StageTitleBar.css"; +import UINode, {UINodeOptions} from "@/ui/UINode"; +import {ISubscription} from "@/Publisher"; +import BeatGroup, {BeatGroupEvents} from "@/BeatGroup"; +import ISubscriber from "@/Subscriber"; +import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView"; + +export type StageTitleBarViewOptions = UINodeOptions & { + beatGroup: BeatGroup, +}; + +const EventTypeSubscription = [BeatGroupEvents.NameChanged]; +type EventTypeSubscription = FlatArray; + +export default class StageTitleBarView extends UINode implements ISubscriber { + private sub: ISubscription; + private beatGroup: BeatGroup; + private title: EditableTextFieldView; + + constructor(options: StageTitleBarViewOptions) { + super(options); + this.beatGroup = options.beatGroup; + this.sub = options.beatGroup.addSubscriber(this, EventTypeSubscription); + this.title = new EditableTextFieldView({ + initialText: this.beatGroup.getName(), + setter: (text) => this.beatGroup.setName(text), + noEmpty: true, + }); + } + + notify(publisher: unknown, event: EventTypeSubscription): void { + if (event === BeatGroupEvents.NameChanged) { + this.title.setText(this.beatGroup.getName()); + } + } + + setBeatGroup(beatGroup: BeatGroup): void { + this.sub.unbind(); + this.beatGroup = beatGroup; + this.sub = beatGroup.addSubscriber(this, EventTypeSubscription); + this.notify(this, BeatGroupEvents.NameChanged); + } + + protected build(): HTMLElement { + return UINode.make("div", {classes: ["stage-title-bar"]}, [ + UINode.make("div", {classes: ["stage-title-bar-preamble"], innerText: "Currently editing:"}), + UINode.make("h2", {}, [this.title]), + ]); + } +} diff --git a/src/ui/UINode.ts b/src/ui/UINode.ts index daaa393..f6d77c9 100644 --- a/src/ui/UINode.ts +++ b/src/ui/UINode.ts @@ -13,7 +13,7 @@ type IRenderAttributes< export default abstract class UINode { protected node: HTMLElement | null = null; - constructor(options: UINodeOptions) {} + constructor(options: UINodeOptions) { /* dummy */ } render(): HTMLElement { if (!this.node) { @@ -51,7 +51,7 @@ export default abstract class UINode { K extends keyof HTMLElementTagNameMap[T]>( type: T, attributes: IRenderAttributes, - subElements?: HTMLElement[], + subNodes?: (Node | UINode)[], ): HTMLElementTagNameMap[T] { const element = document.createElement(type); if (attributes) { @@ -63,8 +63,14 @@ export default abstract class UINode { } } } - if (subElements) { - element.append(...subElements); + if (subNodes) { + for (const subElement of subNodes) { + if (subElement instanceof UINode) { + element.append(subElement.render()); + } else { + element.append(subElement); + } + } } return element; } diff --git a/src/ui/Widgets/ActionButton/ActionButtonView.ts b/src/ui/Widgets/ActionButton/ActionButtonView.ts index f1281f0..3f139dc 100644 --- a/src/ui/Widgets/ActionButton/ActionButtonView.ts +++ b/src/ui/Widgets/ActionButton/ActionButtonView.ts @@ -50,16 +50,15 @@ export default class ActionButtonView extends UINode { protected build(): HTMLButtonElement { this.buttonElement = UINode.make("button", { classes: ["action-button", `action-button-${this.type}`], - onclick: (event: MouseEvent) => this.disabled || this.onClick(event), - subs: [ - this.icon !== null ? new IconView({ - iconName: this.icon, - color: "var(--color-p-light)", - }).render() : UINode.make("span", { - innerText: this.label ?? "" - }), - ], - }); + onclick: (event: MouseEvent) => this.disabled || this.onClick(event) + }, [ + this.icon !== null ? new IconView({ + iconName: this.icon, + color: "var(--color-p-light)", + }).render() : UINode.make("span", { + innerText: this.label ?? "" + }), + ]); if (this.alt) { this.buttonElement.title = this.alt; } diff --git a/src/ui/Widgets/BoolBox/BoolBoxView.ts b/src/ui/Widgets/BoolBox/BoolBoxView.ts index 7f47b9d..f9bea54 100644 --- a/src/ui/Widgets/BoolBox/BoolBoxView.ts +++ b/src/ui/Widgets/BoolBox/BoolBoxView.ts @@ -55,10 +55,9 @@ export default class BoolBoxView extends UINode { }); return UINode.make("div", { classes: ["bool-box"], - subs: [ - this.labelElement, - this.checkboxElement, - ], - }); + },[ + this.labelElement, + this.checkboxElement, + ]); } } \ No newline at end of file diff --git a/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.css b/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.css new file mode 100644 index 0000000..d22ec72 --- /dev/null +++ b/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.css @@ -0,0 +1,14 @@ +input.editable-text-field-view { + width: fit-content; + max-width: 200px; +} + +div.editable-text-field-view { + width: 100%; + transition: background-color 200ms; + cursor: pointer; +} + +div.editable-text-field-view:hover { + background-color: var(--color-ui-neutral-dark-hover); +} \ No newline at end of file diff --git a/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.ts b/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.ts new file mode 100644 index 0000000..91f697c --- /dev/null +++ b/src/ui/Widgets/EditableTextFIeld/EditableTextFieldView.ts @@ -0,0 +1,71 @@ +import UINode, {UINodeOptions} from "@/ui/UINode"; +import "./EditableTextFieldView.css"; + +export type EditableTextFieldViewOptions = UINodeOptions & { + initialText?: string, + setter?: (newString: string) => void, + noEmpty?: boolean, +}; + +export default class EditableTextFieldView extends UINode { + private text: string; + private titleInput!: HTMLInputElement; + private setter: (newString: string) => void; + private titleDisplay!: HTMLElement; + private noEmpty: boolean; + private lastNonEmptyInput = ""; + + constructor(options: EditableTextFieldViewOptions) { + super(options); + this.setter = options.setter ?? (() => {/* dummy */}); + this.text = options.initialText ?? ""; + this.noEmpty = options.noEmpty ?? false; + } + + setText(newText: string): void { + if (newText !== "" || !this.noEmpty) { + this.text = newText; + this.titleInput.value = this.text; + this.titleDisplay.innerText = this.text; + } + } + + build(): HTMLSpanElement { + this.titleInput = UINode.make("input", { + value: this.text, + classes: ["editable-text-field-view"], + type: "text", + oninput: (event: Event) => { + const input = (event.target as HTMLInputElement).value; + if (input === "") { + if (!this.noEmpty) { + this.setter(input); + } + } else { + this.setter(input); + this.lastNonEmptyInput = input; + } + }, + onblur: (event: FocusEvent) => { + if ((event.target as HTMLInputElement).value === "") { + this.setText(this.lastNonEmptyInput); + } + this.titleInput.replaceWith(this.titleDisplay); + }, + onkeyup: (event: KeyboardEvent) => { + if (event.key === "Enter") { + (event.target as HTMLInputElement).blur(); + } + }, + }); + this.titleDisplay = UINode.make("div", { + innerText: this.text, + classes: ["editable-text-field-view"], + onclick: () => { + this.titleDisplay.replaceWith(this.titleInput); + this.titleInput.focus(); + }, + }); + return this.titleDisplay; + } +} \ No newline at end of file diff --git a/src/ui/Widgets/NumberInput/NumberInputView.ts b/src/ui/Widgets/NumberInput/NumberInputView.ts index 8edcb8d..037666a 100644 --- a/src/ui/Widgets/NumberInput/NumberInputView.ts +++ b/src/ui/Widgets/NumberInput/NumberInputView.ts @@ -101,32 +101,31 @@ export default class NumberInputView extends UINode { }); return UINode.make("div", { classes: ["number-input"], - subs: [ - this.labelElement, - UINode.make("button", { - innerText: "-", - classes: ["number-input-button", "number-input-dec"], - onclick: () => { - if (this.onDecrement) { - this.onDecrement(); - } else if (this.setter && this.getter) { - this.setter(this.getter() - 1); - } - }, - }), - this.inputElement, - UINode.make("button", { - innerText: "+", - classes: ["number-input-button", "number-input-inc"], - onclick: () => { - if (this.onIncrement) { - this.onIncrement(); - } else if (this.setter && this.getter) { - this.setter(this.getter() + 1); - } - }, - }), - ], - }); + }, [ + this.labelElement, + UINode.make("button", { + innerText: "-", + classes: ["number-input-button", "number-input-dec"], + onclick: () => { + if (this.onDecrement) { + this.onDecrement(); + } else if (this.setter && this.getter) { + this.setter(this.getter() - 1); + } + }, + }), + this.inputElement, + UINode.make("button", { + innerText: "+", + classes: ["number-input-button", "number-input-inc"], + onclick: () => { + if (this.onIncrement) { + this.onIncrement(); + } else if (this.setter && this.getter) { + this.setter(this.getter() + 1); + } + }, + }), + ]); } } \ No newline at end of file