refactor: moved to new @djledda/ladder and vite

This commit is contained in:
Daniel Ledda
2022-05-28 19:05:29 +02:00
parent b4e3ecfac6
commit 9224cea4dc
31 changed files with 977 additions and 7058 deletions

View File

@@ -16,6 +16,10 @@
"@typescript-eslint" "@typescript-eslint"
], ],
"rules": { "rules": {
"object-curly-spacing": [
"error",
"always"
],
"indent": [ "indent": [
"error", "error",
4 4

View File

@@ -7,11 +7,9 @@
<title>Drum Slayer</title> <title>Drum Slayer</title>
<link rel='icon' type='image/png' href='./favicon.png'> <link rel='icon' type='image/png' href='./favicon.png'>
<link rel='stylesheet' href='./static/main.css'>
<script defer src='./static/bundle.js'></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src='/src/main.ts'></script>
</body> </body>
</html> </html>

6580
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,22 +14,14 @@
"author": "Daniel Ledda", "author": "Daniel Ledda",
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.29.2", "@typescript-eslint/eslint-plugin": "^5.26.0",
"@typescript-eslint/parser": "^4.29.2", "@typescript-eslint/parser": "^5.26.0",
"@webpack-cli/generators": "^2.3.0", "eslint": "^8.16.0",
"css-loader": "^6.2.0", "typescript": "^4.7.2",
"eslint": "^7.32.0", "vite": "^2.9.9"
"mini-css-extract-plugin": "^2.2.0",
"source-map-support": "^0.5.19",
"style-loader": "^3.2.1",
"ts-loader": "^9.2.5",
"tslib": "^2.0.0",
"typescript": "^4.4.0-insiders.20210805",
"webpack": "^5.51.1",
"webpack-cli": "^4.8.0",
"webpack-dev-server": "^4.0.0"
}, },
"dependencies": { "dependencies": {
"@djledda/ladder": "^1.0.2",
"file-loader": "^6.2.0" "file-loader": "^6.2.0"
} }
} }

View File

@@ -1,8 +1,6 @@
import Track, {TrackEvents, TrackInitOptions} from "@/Track"; import Track, { TrackEvents, TrackInitOptions } from "@/Track";
import {IPublisher, Publisher} from "@/Publisher"; import { greatestCommonDivisor, isPosInt } from "@/utils";
import ISubscriber from "@/Subscriber"; import { Capsule, ICapsule, IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
import {greatestCommonDivisor, isPosInt} from "@/utils";
import Ref from "@/Ref";
type BeatGroupInitOptions = { type BeatGroupInitOptions = {
barCount: number; barCount: number;
@@ -56,14 +54,14 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
private globalIsLooping: boolean; private globalIsLooping: boolean;
private useAutoBeatLength: boolean; private useAutoBeatLength: boolean;
private barSettingsLocked = false; private barSettingsLocked = false;
private name: Ref<string>; private name: ICapsule<string>;
constructor(options?: BeatGroupInitOptions) { constructor(options?: BeatGroupInitOptions) {
Beat.globalCounter++; Beat.globalCounter++;
if (options?.name) { if (options?.name) {
this.name = Ref.new<string>(options.name); this.name = Capsule.new<string>(options.name);
} else { } else {
this.name = Ref.new<string>(`Pattern ${Beat.globalCounter}`); this.name = Capsule.new<string>(`Pattern ${Beat.globalCounter}`);
} }
if (options?.tracks) { if (options?.tracks) {
for (const trackOptions of options.tracks) { for (const trackOptions of options.tracks) {
@@ -185,7 +183,7 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
} }
this.timeSigUp = timeSigUp; this.timeSigUp = timeSigUp;
for (const track of this.tracks) { for (const track of this.tracks) {
track.setTimeSignature({up: timeSigUp}); track.setTimeSignature({ up: timeSigUp });
} }
this.autoBeatLength(); this.autoBeatLength();
this.publisher.notifySubs(BeatEvents.TimeSigUpChanged); this.publisher.notifySubs(BeatEvents.TimeSigUpChanged);
@@ -332,7 +330,7 @@ export default class Beat implements IPublisher<BeatEvents>, ISubscriber<EventTy
this.name.val = newName; this.name.val = newName;
} }
getName(): Ref<string> { getName(): ICapsule<string> {
return this.name; return this.name;
} }

View File

@@ -1,6 +1,5 @@
import Beat, {BeatEvents} from "@/Beat"; import Beat, { BeatEvents } from "@/Beat";
import Ref from "@/Ref"; import { Capsule, ICapsule, ISubscriber } from "@djledda/ladder";
import ISubscriber from "@/Subscriber";
const EventTypeSubscriptions = [ const EventTypeSubscriptions = [
BeatEvents.TimeSigUpChanged, BeatEvents.TimeSigUpChanged,
@@ -15,7 +14,7 @@ type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatStore implements ISubscriber<EventTypeSubscriptions> { export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
private readonly beats: Beat[]; private readonly beats: Beat[];
private activeBeat: Ref<Beat>; private activeBeat: ICapsule<Beat>;
private onBeatChangeCbs: (() => void)[] = []; private onBeatChangeCbs: (() => void)[] = [];
private autoSave: boolean; private autoSave: boolean;
private orientation: "horizontal" | "vertical" | null = null; private orientation: "horizontal" | "vertical" | null = null;
@@ -27,7 +26,7 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
if (save) { if (save) {
const serial = JSON.parse(save); const serial = JSON.parse(save);
this.beats = [BeatStore.defaultMainBeatGroup()]; this.beats = [BeatStore.defaultMainBeatGroup()];
this.activeBeat = Ref.new(this.beats[0]); this.activeBeat = Capsule.new(this.beats[0]);
this.loadFromSave(serial); this.loadFromSave(serial);
if (this.autoSave) { if (this.autoSave) {
this.activeBeat.watch(() => this.save("localStorage"), true); this.activeBeat.watch(() => this.save("localStorage"), true);
@@ -39,7 +38,7 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
this.beats = [ this.beats = [
BeatStore.defaultMainBeatGroup(), BeatStore.defaultMainBeatGroup(),
]; ];
this.activeBeat = Ref.new(this.beats[0]); this.activeBeat = Capsule.new(this.beats[0]);
} }
notify(publisher: unknown, event: EventTypeSubscriptions): void { notify(publisher: unknown, event: EventTypeSubscriptions): void {
@@ -53,14 +52,14 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
timeSigUp: 8, timeSigUp: 8,
}; };
const mainBeatGroup = new Beat(defaultSettings); const mainBeatGroup = new Beat(defaultSettings);
mainBeatGroup.addTrack({name: "LF"}); mainBeatGroup.addTrack({ name: "LF" });
mainBeatGroup.addTrack({name: "LH"}); mainBeatGroup.addTrack({ name: "LH" });
mainBeatGroup.addTrack({name: "RH"}); mainBeatGroup.addTrack({ name: "RH" });
mainBeatGroup.addTrack({name: "RF"}); mainBeatGroup.addTrack({ name: "RF" });
return mainBeatGroup; return mainBeatGroup;
} }
getActiveBeat(): Ref<Beat> { getActiveBeat(): ICapsule<Beat> {
return this.activeBeat; return this.activeBeat;
} }

View File

@@ -1,91 +0,0 @@
import ISubscriber, {LEvent} from "./Subscriber";
class PublisherSubscription<EventType extends LEvent> implements ISubscription {
private subscriber: ISubscriber<EventType>;
private readonly eventTypes: EventType[];
private unbindCallback?: () => void;
constructor(subscriber: ISubscriber<EventType>, eventTypes: EventType[]) {
this.subscriber = subscriber;
this.eventTypes = eventTypes;
}
setUnbind(unbind: () => void): void {
this.unbindCallback = unbind;
}
unbind(): void {
this.unbindCallback?.();
}
getEventTypes(): EventType[] {
return this.eventTypes;
}
getSubscriber(): ISubscriber<EventType> {
return this.subscriber;
}
}
interface EventSubscriberRecord<T extends LEvent> {
get<K extends T>(key: K): ISubscriber<K>[];
set<K extends T>(key: K, subscribers: ISubscriber<K>[]): EventSubscriberRecord<T>;
}
export class Publisher<EventType extends LEvent, PublisherType> implements IPublisher<EventType> {
private subscribers: EventSubscriberRecord<EventType>;
private parent: PublisherType;
constructor(parent: PublisherType) {
this.parent = parent;
this.subscribers = new Map();
}
addSubscriber(subscriber: ISubscriber<EventType>, subscribeTo: EventType | Readonly<EventType[]>): ISubscription {
let eventTypes: EventType[] = [];
if (typeof subscribeTo === "string") {
eventTypes.push(subscribeTo);
} else {
eventTypes = subscribeTo.slice();
}
for (const key of eventTypes) {
this.getSubscribers(key).push(subscriber);
}
const sub = new PublisherSubscription(subscriber, eventTypes);
sub.setUnbind(() => this.unbind(sub));
return sub;
}
private unbind(subscription: PublisherSubscription<EventType>): void {
for (const key of subscription.getEventTypes()) {
const subs = this.getSubscribers(key);
subs.splice(subs.indexOf(subscription.getSubscriber()), 1);
}
}
private getSubscribers<K extends EventType>(key: K): ISubscriber<K>[] {
const subscribersList = this.subscribers.get(key);
if (subscribersList === undefined) {
const newList: ISubscriber<K>[] = [];
this.subscribers.set(key, newList);
return newList;
} else {
return subscribersList;
}
}
notifySubs<K extends EventType>(eventType: K): void {
for (const sub of this.getSubscribers(eventType)) {
sub.notify(this.parent, eventType);
}
}
}
export interface IPublisher<T extends LEvent> {
addSubscriber(subscriber: ISubscriber<T>, subscribeTo: T | T[]): {unbind: () => void};
}
export interface ISubscription {
unbind(): void;
}

View File

@@ -1,99 +0,0 @@
import {ISubscription} from "@/Publisher";
export type MaybeRef<T> = T | Ref<T>;
class RefSubscription implements ISubscription {
private unbindCallback?: () => void;
constructor(unbindCallback: () => void) {
this.unbindCallback = unbindCallback;
}
unbind(): void {
this.unbindCallback?.();
}
}
interface Stringable {
toString(): string;
}
type AllowedRef = { toString(): string } | string | null;
export default class Ref<T extends AllowedRef = Stringable> {
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;
private constructor(val: T) {
this.value = val;
this.isString = typeof val === "string";
}
static new<T extends AllowedRef>(val: MaybeRef<T>): Ref<T> {
if (val instanceof Ref) {
return val;
} else {
return new Ref<T>(val);
}
}
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);
}
return new RefSubscription(() => this.unbind(watcher, !!after));
}
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);
}
}
}
get val(): T {
return this.value;
}
set val(val: T) {
this.watchers?.forEach(watcher => watcher(val));
this.value = val;
this.afterWatchers?.forEach(watcher => watcher(val));
}
toString(): string {
if (!this.asString) {
if (this.isString) {
return this.val as unknown as string;
} else {
this.asString = this.val?.toString() ?? "null";
}
}
return this.asString;
}
}

View File

@@ -1,4 +0,0 @@
export type LEvent = string;
export default interface ISubscriber<T extends LEvent> {
notify(publisher: unknown, event: T): void;
}

View File

@@ -1,7 +1,6 @@
import TrackUnit, {TrackUnitType} from "@/TrackUnit"; import TrackUnit, { TrackUnitType } from "@/TrackUnit";
import {IPublisher, Publisher} from "@/Publisher"; import { isPosInt } from "@/utils";
import ISubscriber from "@/Subscriber"; import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
import {isPosInt} from "@/utils";
export type TrackInitOptions = { export type TrackInitOptions = {
timeSig?: { timeSig?: {
@@ -53,7 +52,7 @@ export default class Track implements IPublisher<TrackEvents> {
constructor(options?: TrackInitOptions) { constructor(options?: TrackInitOptions) {
this.key = `B-${Track.count}`; this.key = `B-${Track.count}`;
this.name = options?.name ?? this.key; this.name = options?.name ?? this.key;
this.setTimeSignature({up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4}); this.setTimeSignature({ up: options?.timeSig?.up ?? 4, down: options?.timeSig?.down ?? 4 });
this.setBarCount(options?.bars ?? 4); this.setBarCount(options?.bars ?? 4);
Track.count++; Track.count++;
this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount; this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount;
@@ -113,11 +112,11 @@ export default class Track implements IPublisher<TrackEvents> {
} }
setTimeSigUp(timeSigUp: number): void { setTimeSigUp(timeSigUp: number): void {
this.setTimeSignature({up: timeSigUp}); this.setTimeSignature({ up: timeSigUp });
} }
setTimeSigDown(timeSigUp: number): void { setTimeSigDown(timeSigUp: number): void {
this.setTimeSignature({down: timeSigUp}); this.setTimeSignature({ down: timeSigUp });
} }
setBarCount(barCount: number): void { setBarCount(barCount: number): void {

View File

@@ -1,6 +1,5 @@
import {IPublisher, Publisher} from "./Publisher";
import ISubscriber from "./Subscriber";
import Track from "@/Track"; import Track from "@/Track";
import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
export const enum TrackUnitType { export const enum TrackUnitType {
Normal="tut-0", Normal="tut-0",

9
src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import RootView from "@/ui/Root/RootView";
declare global {
interface Window {
appRoot?: RootView;
}
}
export {};

View File

@@ -9,8 +9,6 @@ if (appNode) {
orientation: "vertical", orientation: "vertical",
title: "Drum Slayer", title: "Drum Slayer",
}); });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.appRoot = appRoot; window.appRoot = appRoot;
appNode.appendChild(appRoot.render()); appNode.appendChild(appRoot.render());
console.log("OK!"); console.log("OK!");

View File

@@ -1,12 +1,10 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode"; import { h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
import Beat, {BeatEvents} from "@/Beat"; import Beat, { BeatEvents } from "@/Beat";
import TrackView from "@/ui/Track/TrackView"; import TrackView from "@/ui/Track/TrackView";
import "./Beat.css"; import "./Beat.css";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView"; import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
export type BeatUINodeOptions = UINodeOptions & { export type BeatUINodeOptions = RungOptions & {
beat: Beat, beat: Beat,
orientation?: "horizontal" | "vertical", orientation?: "horizontal" | "vertical",
}; };
@@ -16,7 +14,7 @@ const EventTypeSubscriptions = [
] as const; ] as const;
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class BeatView extends Rung<HTMLElement> implements ISubscriber<EventTypeSubscriptions> {
private beat: Beat; private beat: Beat;
private title: EditableTextFieldView; private title: EditableTextFieldView;
private trackViews: TrackView[] = []; private trackViews: TrackView[] = [];
@@ -50,7 +48,7 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
if (beat && this.trackViews[i]) { if (beat && this.trackViews[i]) {
this.trackViews[i].setBeat(beat); this.trackViews[i].setBeat(beat);
} else { } else {
this.trackViews.push(new TrackView({track: this.beat.getTrackByIndex(i)})); this.trackViews.push(new TrackView({ track: this.beat.getTrackByIndex(i) }));
} }
} }
const deadTrackViews = this.trackViews.splice(newCount, this.trackViews.length - newCount); const deadTrackViews = this.trackViews.splice(newCount, this.trackViews.length - newCount);
@@ -69,7 +67,7 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
private reverseDisplayOrder(): void { private reverseDisplayOrder(): void {
this.trackViews.reverse(); this.trackViews.reverse();
this.getNode().classList.toggle("vertical"); this.render().classList.toggle("vertical");
this.redraw(); this.redraw();
} }
@@ -91,19 +89,9 @@ export default class BeatView extends UINode implements ISubscriber<EventTypeSub
} }
build(): HTMLDivElement { build(): HTMLDivElement {
return h("div", { return <div className={"beat"}>
className: "beat", <h2 className={"beat-title"}>{this.title}</h2>
},[ <div className={"beat-track-container"}>{...this.trackViews}</div>
h("h2", { </div> as HTMLDivElement;
className: "beat-title",
}, [
this.title,
]),
h("div", {
className: "beat-track-container",
}, [
...this.trackViews,
]),
]);
} }
} }

View File

@@ -1,13 +1,12 @@
import "./BeatSettings.css"; import "./BeatSettings.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView"; import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
import ISubscriber from "@/Subscriber"; import Beat, { BeatEvents } from "@/Beat";
import Beat, {BeatEvents} from "@/Beat";
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView"; import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
import TrackSettingsView from "@/ui/TrackSettings/TrackSettingsView"; import TrackSettingsView from "@/ui/TrackSettings/TrackSettingsView";
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView"; import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
import { h, ISubscriber, Rung, RungOptions } from "@djledda/ladder";
export type BeatSettingsUINodeOptions = UINodeOptions & { export type BeatSettingsUINodeOptions = RungOptions & {
beat: Beat, beat: Beat,
}; };
@@ -21,7 +20,7 @@ const EventTypeSubscriptions = [
]; ];
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class BeatSettingsView extends Rung implements ISubscriber<EventTypeSubscriptions> {
private beat: Beat; private beat: Beat;
private barCountInput!: NumberInputView; private barCountInput!: NumberInputView;
private timeSigUpInput!: NumberInputView; private timeSigUpInput!: NumberInputView;
@@ -82,13 +81,13 @@ export default class BeatSettingsView extends UINode implements ISubscriber<Even
} }
} }
if (!this.trackSettingsContainer) { if (!this.trackSettingsContainer) {
this.trackSettingsContainer = h("div", {}, this.trackSettingsViews); this.trackSettingsContainer = <div>{...this.trackSettingsViews}</div> as HTMLDivElement;
} else { } else {
this.trackSettingsContainer.replaceChildren(...this.trackSettingsViews.reverse().map(view => view.render())); this.trackSettingsContainer.replaceChildren(...this.trackSettingsViews.reverse().map(view => view.render()));
} }
} }
build(): HTMLElement { build(): Node {
this.barCountInput = new NumberInputView({ this.barCountInput = new NumberInputView({
label: "Bars:", label: "Bars:",
initialValue: this.beat.getBarCount(), initialValue: this.beat.getBarCount(),
@@ -107,34 +106,23 @@ export default class BeatSettingsView extends UINode implements ISubscriber<Even
onInput: (isChecked: boolean) => this.beat.setIsUsingAutoBeatLength(isChecked), onInput: (isChecked: boolean) => this.beat.setIsUsingAutoBeatLength(isChecked),
}); });
this.remakeBeatSettingsViews(); this.remakeBeatSettingsViews();
return h("div", { return <div className={"beat-settings"}>
classes: ["beat-settings"], <div className={"beat-settings-options"}>
}, [ <div classes={["beat-settings-boxes", "beat-settings-option"]}>
h("div", { {this.timeSigUpInput}
classes: ["beat-settings-options"], </div>
}, [ <div classes={["beat-settings-bar-count", "beat-settings-option"]}>
h("div", { {this.barCountInput}
classes: ["beat-settings-boxes", "beat-settings-option"], </div>
}, [ <div classes={["beat-settings-bar-count", "beat-settings-option"]}>
this.timeSigUpInput, {this.autoBeatLengthCheckbox}
]), </div>
h("div", { {new ActionButtonView({
classes: ["beat-settings-bar-count", "beat-settings-option"]
,
}, [
this.barCountInput,
]),
h("div", {
classes: ["beat-settings-bar-count", "beat-settings-option"],
}, [
this.autoBeatLengthCheckbox,
]),
new ActionButtonView({
label: "New Track", label: "New Track",
onClick: () => this.beat.addTrack(), onClick: () => this.beat.addTrack(),
}), })}
this.trackSettingsContainer, {this.trackSettingsContainer}
]), </div>
]); </div>;
} }
} }

View File

@@ -1,194 +0,0 @@
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 Ref from "@/Ref";
import BeatStore from "@/BeatStore";
export type RootUINodeOptions = UINodeOptions & {
title: string,
mainBeat?: Beat,
orientation?: "horizontal" | "vertical",
};
export default class RootView extends UINode {
private title: string;
private beatView: BeatView;
private beatStore: BeatStore;
private activeBeat: Ref<Beat>;
private beatSettingsView: BeatSettingsView;
private currentOrientation: "horizontal" | "vertical";
private showHideSidebarButton: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
private sidebarActive = true;
private sidebarLeftTabs: Ref<HTMLDivElement | null> = Ref.new<HTMLDivElement | null>(null);
constructor(options: RootUINodeOptions) {
super(options);
this.beatStore = new BeatStore({
loadFromLocalStorage: true,
autoSave: true,
});
this.activeBeat = this.beatStore.getActiveBeat();
this.activeBeat.watch((newVal) => {
this.beatSettingsView.setBeat(newVal);
this.beatView.setBeat(newVal);
});
this.currentOrientation = this.beatStore.getSavedOrientation() ?? options.orientation ?? "horizontal";
this.beatView = new BeatView({
beat: this.activeBeat.val,
orientation: this.currentOrientation,
});
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();
}
private openSidebarForDesktop() {
const mediaQueryList = window.matchMedia("screen and (max-width: 900px)");
if (mediaQueryList.matches) {
this.toggleSidebar();
}
}
toggleSidebar(): void {
this.sidebarActive = !this.sidebarActive;
this.showHideSidebarButton.val!.title = this.sidebarText();
this.getNode().classList.toggle("sidebar-visible");
}
toggleOrientation(): void {
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.beatStore.setOrientation(orientation);
this.beatView.setOrientation(orientation);
}
private sidebarText(): string {
return `${this.sidebarActive ? "Hide" : "Show"} sidebar`;
}
private buildSidebarStripLeft(): HTMLElement {
return h("div", {
className: "root-sidebar-left-strip",
}, [
h("div", {
saveTo: this.sidebarLeftTabs
}, this.buildTabs()),
h("div", {
className: "root-sidebar-add-beat",
onclick: () => this.beatStore.addNewBeat(),
innerText: "+",
}),
]);
}
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;
});
}
private buildSidebarQuickButtons(): HTMLElement {
return h("div", {
classes: ["root-sidebar-toggle"],
}, [
h("div", {
classes: ["root-quick-access-button"],
title: this.sidebarText(),
saveTo: this.showHideSidebarButton,
onclick: () => this.toggleSidebar(),
}, [
new IconView({
iconName: "list",
color: "var(--color-ui-neutral-dark)"
})
]),
h("div", {
classes: ["root-quick-access-button"],
title: "Change orientation",
onclick: () => this.toggleOrientation(),
}, [
new IconView({
iconName: "arrowClockwise",
color: "var(--color-ui-neutral-dark)"
}),
]),
h("div", {
classes: ["root-quick-access-button"],
title: "Bake all tracks",
onclick: () => this.activeBeat.val.bakeLoops(),
}, [
new IconView({
iconName: "snowflake",
color: "var(--color-ui-neutral-dark)"
}),
]),
h("div", {
classes: ["root-quick-access-button"],
title: "Reset all",
onclick: () => this.beatStore.resetActiveBeat(),
}, [
new IconView({
iconName: "trash",
color: "var(--color-ui-neutral-dark)"
})
]),
]);
}
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.buildSidebarQuickButtons(),
])
);
}
build(): HTMLElement {
return (
h("div", {classes: ["root", "sidebar-visible"]}, [
this.buildSidebar(),
h("div", {classes: ["root-beat-stage-container"]}, [
h("div", {classes: ["root-beat-stage"]}, [
this.beatView,
])
])
])
);
}
}

184
src/ui/Root/RootView.tsx Normal file
View File

@@ -0,0 +1,184 @@
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 BeatStore from "@/BeatStore";
import { Capsule, h, frag, Rung, RungOptions, ICapsule } from "@djledda/ladder";
export type RootUINodeOptions = RungOptions & {
title: string,
mainBeat?: Beat,
orientation?: "horizontal" | "vertical",
};
export default class RootView extends Rung<HTMLDivElement> {
private title: string;
private beatView: BeatView;
private beatStore: BeatStore;
private activeBeat: ICapsule<Beat>;
private beatSettingsView: BeatSettingsView;
private currentOrientation: "horizontal" | "vertical";
private showHideSidebarButton = Capsule.new<HTMLDivElement | null>(null);
private sidebarActive = true;
private sidebarLeftTabs = Capsule.new<HTMLDivElement | null>(null);
constructor(options: RootUINodeOptions) {
super(options);
this.beatStore = new BeatStore({
loadFromLocalStorage: true,
autoSave: true,
});
this.activeBeat = this.beatStore.getActiveBeat();
this.activeBeat.watch((newVal) => {
this.beatSettingsView.setBeat(newVal);
this.beatView.setBeat(newVal);
});
this.currentOrientation = this.beatStore.getSavedOrientation() ?? options.orientation ?? "horizontal";
this.beatView = new BeatView({
beat: this.activeBeat.val,
orientation: this.currentOrientation,
});
this.beatStore.onBeatChanges(() => {
this.sidebarLeftTabs.val?.replaceChildren(<this.Tabs />);
});
this.beatSettingsView = new BeatSettingsView({ beat: this.activeBeat.val });
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();
}
}
toggleSidebar(): void {
this.sidebarActive = !this.sidebarActive;
if (this.showHideSidebarButton.val) {
this.showHideSidebarButton.val.title = this.sidebarText();
}
this.render().classList.toggle("sidebar-visible");
}
toggleOrientation(): void {
if (this.currentOrientation === "vertical") {
this.setOrientation("horizontal");
} else {
this.setOrientation("vertical");
}
}
setOrientation(orientation: "horizontal" | "vertical"): void {
this.currentOrientation = orientation;
if (orientation === "vertical") {
this.render().classList.add("vertical-mode");
} else {
this.render().classList.remove("vertical-mode");
}
this.beatStore.setOrientation(orientation);
this.beatView.setOrientation(orientation);
}
private sidebarText(): string {
return `${this.sidebarActive ? "Hide" : "Show"} sidebar`;
}
private SidebarStripLeft = (): HTMLElement => {
return <div className={"root-sidebar-left-strip"}>
<div saveTo={this.sidebarLeftTabs}>
<this.Tabs />
</div>
<div className={"root-sidebar-add-beat"} onclick={() => this.beatStore.addNewBeat()}>+</div>
</div> as HTMLElement;
};
private Tabs = (): Node => {
return <div>
{...this.beatStore.getBeats().map((beat) => {
const node = <div
className={"root-sidebar-left-tab" + (beat === this.activeBeat.val ? " active" : "")}
onclick={() => this.beatStore.setActiveBeat(beat)}>
{beat.getName()}
</div> as HTMLDivElement;
this.activeBeat.watch((newVal) => {
if (beat === newVal) {
node.classList.add("active");
} else {
node.classList.remove("active");
}
});
return node;
})}
</div>;
};
private SidebarQuickButtons = (): HTMLElement => {
return <div className={"root-sidebar-toggle"}>
<div
className={"root-quick-access-button"}
title={this.sidebarText()}
saveTo={this.showHideSidebarButton}
onclick={() => this.toggleSidebar()}>
{new IconView({
iconName: "list",
color: "var(--color-ui-neutral-dark)"
})}
</div>
<div
className={"root-quick-access-button"}
title={"Change orientation"}
onclick={() => this.toggleOrientation()}>
{new IconView({
iconName: "arrowClockwise",
color: "var(--color-ui-neutral-dark)"
})}
</div>
<div
className={"root-quick-access-button"}
title={"Bake all tracks"}
onclick={() => this.activeBeat.val.bakeLoops()}>
{new IconView({
iconName: "snowflake",
color: "var(--color-ui-neutral-dark)"
})}
</div>
<div
className={"root-quick-access-button"}
title={"Reset all"}
onclick={() => this.beatStore.resetActiveBeat()}>
{new IconView({
iconName: "trash",
color: "var(--color-ui-neutral-dark)"
})}
</div>
</div> as HTMLElement;
};
private Sidebar = (): HTMLElement => {
return <div className={"root-sidebar"}>
<this.SidebarStripLeft />
<div
className={"root-settings"}>
<h1 className={"root-title"}>{this.title}</h1>
{this.beatSettingsView}
</div>
<this.SidebarQuickButtons />
</div> as HTMLElement;
};
build(): HTMLDivElement {
return (
<div classes={["root", "sidebar-visible"]}>
<this.Sidebar/>
<div className={"root-beat-stage-container"}>
<div className={"root-beat-stage"}>
{this.beatView}
</div>
</div>
</div>
) as HTMLDivElement;
}
}

View File

@@ -1,12 +1,9 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode"; import Track, { TrackEvents } from "@/Track";
import Track, {TrackEvents} from "@/Track";
import ISubscriber from "@/Subscriber";
import TrackUnitView from "@/ui/TrackUnit/TrackUnitView"; import TrackUnitView from "@/ui/TrackUnit/TrackUnitView";
import "./Track.css"; import "./Track.css";
import {ISubscription} from "@/Publisher"; import { Capsule, h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
import Ref from "@/Ref";
export type TrackUINodeOptions = UINodeOptions & { export type TrackUINodeOptions = RungOptions & {
track: Track, track: Track,
}; };
@@ -20,9 +17,9 @@ const EventTypeSubscriptions = [
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackView extends Rung implements ISubscriber<EventTypeSubscriptions> {
private track!: Track; private track!: Track;
private title = Ref.new<HTMLHeadingElement | null>(null); private title = Capsule.new<HTMLHeadingElement | null>(null);
private trackUnitViews: TrackUnitView[] = []; private trackUnitViews: TrackUnitView[] = [];
private trackUnitViewBlock: HTMLElement | null = null; private trackUnitViewBlock: HTMLElement | null = null;
private lastHoveredTrackUnitView: TrackUnitView | null = null; private lastHoveredTrackUnitView: TrackUnitView | null = null;
@@ -70,7 +67,7 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
view = this.trackUnitViews[i]; view = this.trackUnitViews[i];
view.setUnit(trackUnit); view.setUnit(trackUnit);
} else { } else {
view = new TrackUnitView({trackUnit}); view = new TrackUnitView({ trackUnit });
this.trackUnitViews.push(view); this.trackUnitViews.push(view);
view.onHover(() => this.onTrackUnitViewHover(view)); view.onHover(() => this.onTrackUnitViewHover(view));
view.onMouseDown((event: MouseEvent) => this.onTrackUnitClick(event.button, i)); view.onMouseDown((event: MouseEvent) => this.onTrackUnitClick(event.button, i));
@@ -107,11 +104,7 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
if (this.trackUnitViewBlock) { if (this.trackUnitViewBlock) {
this.trackUnitViewBlock.replaceChildren(...trackUnitNodes); this.trackUnitViewBlock.replaceChildren(...trackUnitNodes);
} else { } else {
this.trackUnitViewBlock = h("div", { this.trackUnitViewBlock = <div className={"track-unit-block"}>{...trackUnitNodes}</div> as HTMLDivElement;
classes: ["track-unit-block"],
}, [
...trackUnitNodes
]);
} }
} }
@@ -127,7 +120,7 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
let spacersInserted = false; let spacersInserted = false;
while (!spacersInserted) { while (!spacersInserted) {
i += barLength; i += barLength;
const newSpacer = h("div", {classes: ["track-spacer"]}); const newSpacer = <div className={"track-spacer"} /> as HTMLDivElement;
const leftNeighbour = this.trackUnitViewBlock.children.item(i); const leftNeighbour = this.trackUnitViewBlock.children.item(i);
if (leftNeighbour) { if (leftNeighbour) {
leftNeighbour.insertAdjacentElement("afterend", newSpacer); leftNeighbour.insertAdjacentElement("afterend", newSpacer);
@@ -153,20 +146,16 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
if (!this.trackUnitViewBlock) { if (!this.trackUnitViewBlock) {
throw new Error("Beat unit block setup failed!"); throw new Error("Beat unit block setup failed!");
} }
return h("div", { return <div className={"track"}>
classes: ["track"], <div className={"track-main"}>
}, [ <h3
h("div", { innerText={this.track.getName()}
classes: ["track-main"], saveTo={this.title}
}, [ className={"track-title"}>
h("h3", { </h3>
innerText: this.track.getName(), {this.trackUnitViewBlock}
saveTo: this.title, </div>
classes: ["track-title"], </div> as HTMLElement;
}),
this.trackUnitViewBlock,
]),
]);
} }
} }

View File

@@ -1,14 +1,12 @@
import "./TrackSettings.css"; import "./TrackSettings.css";
import Track, {TrackEvents} from "@/Track"; import Track, { TrackEvents } from "@/Track";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import ISubscriber from "@/Subscriber";
import {ISubscription} from "@/Publisher";
import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView"; import NumberInputView from "@/ui/Widgets/NumberInput/NumberInputView";
import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView"; import BoolBoxView from "@/ui/Widgets/BoolBox/BoolBoxView";
import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView"; import ActionButtonView from "@/ui/Widgets/ActionButton/ActionButtonView";
import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView"; import EditableTextFieldView from "@/ui/Widgets/EditableTextFIeld/EditableTextFieldView";
import { h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
export type BeatSettingsViewUINodeOptions = UINodeOptions & { export type BeatSettingsViewUINodeOptions = RungOptions & {
track: Track, track: Track,
}; };
@@ -19,7 +17,7 @@ const EventTypeSubscriptions = [
]; ];
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackSettingsView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackSettingsView extends Rung implements ISubscriber<EventTypeSubscriptions> {
private track: Track; private track: Track;
private loopLengthInput!: NumberInputView; private loopLengthInput!: NumberInputView;
private bakeButton!: ActionButtonView; private bakeButton!: ActionButtonView;
@@ -67,10 +65,10 @@ export default class TrackSettingsView extends UINode implements ISubscriber<Eve
} }
} }
build(): HTMLElement { build(): Node {
this.title = new EditableTextFieldView({ this.title = new EditableTextFieldView({
initialText: this.track.getName(), initialText: this.track.getName(),
setter: (newText) => this.track.setName(newText), setter: (newText: string) => this.track.setName(newText),
}); });
this.bakeButton = new ActionButtonView({ this.bakeButton = new ActionButtonView({
icon: "snowflake", icon: "snowflake",
@@ -90,41 +88,29 @@ export default class TrackSettingsView extends UINode implements ISubscriber<Eve
value: this.track.isLooping(), value: this.track.isLooping(),
onInput: (isChecked: boolean) => this.track.setLooping(isChecked), onInput: (isChecked: boolean) => this.track.setLooping(isChecked),
}); });
this.loopLengthSection = h("div", { this.loopLengthSection = <div className={"loop-settings-option"}>{this.loopLengthInput}</div> as HTMLDivElement;
classes: ["loop-settings-option"],
}, [
this.loopLengthInput,
]);
if (this.track.isLooping()) { if (this.track.isLooping()) {
this.loopLengthSection.classList.remove("hide"); this.loopLengthSection.classList.remove("hide");
} else { } else {
this.loopLengthSection.classList.add("hide"); this.loopLengthSection.classList.add("hide");
} }
return h("div", { return <div className={"track-settings"}>
classes: ["track-settings"], <div className={"track-settings-title-container"}>
}, [ {this.title}
h("div", { </div>
classes: ["track-settings-title-container"] <div className={"track-settings-lower"}>
}, [ {this.bakeButton}
this.title, {new ActionButtonView({
]),
h("div", {
classes: ["track-settings-lower"],
}, [
this.bakeButton,
new ActionButtonView({
icon: "trash", icon: "trash",
type: "secondary", type: "secondary",
alt: "Delete Track", alt: "Delete Track",
onClick: () => this.track.delete(), onClick: () => this.track.delete(),
}), })}
h("div", { <div className={"loop-settings"}>
classes: ["loop-settings"], {this.loopCheckbox}
}, [ </div>
this.loopCheckbox, {this.loopLengthSection}
]), </div>
this.loopLengthSection, </div>;
]),
]);
} }
} }

View File

@@ -1,10 +1,8 @@
import TrackUnit, {TrackUnitEvent, TrackUnitType} from "@/TrackUnit"; import TrackUnit, { TrackUnitEvent, TrackUnitType } from "@/TrackUnit";
import ISubscriber from "@/Subscriber";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import {IPublisher, ISubscription, Publisher} from "@/Publisher";
import "./TrackUnit.css"; import "./TrackUnit.css";
import { h, IPublisher, ISubscriber, ISubscription, Publisher, Rung, RungOptions } from "@djledda/ladder";
export type TrackUnitUINodeOptions = UINodeOptions & { export type TrackUnitUINodeOptions = RungOptions & {
trackUnit: TrackUnit, trackUnit: TrackUnit,
}; };
@@ -15,7 +13,7 @@ const EventTypeSubscriptions = [
]; ];
type EventTypeSubscriptions = typeof EventTypeSubscriptions[number]; type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class TrackUnitView extends UINode implements ISubscriber<EventTypeSubscriptions> { export default class TrackUnitView extends Rung<HTMLElement> implements ISubscriber<EventTypeSubscriptions> {
private trackUnit: TrackUnit; private trackUnit: TrackUnit;
private subscription: ISubscription | null = null; private subscription: ISubscription | null = null;
private publisher: IPublisher<TrackUnitEvent> = new Publisher<TrackUnitEvent, TrackUnitView>(this); private publisher: IPublisher<TrackUnitEvent> = new Publisher<TrackUnitEvent, TrackUnitView>(this);
@@ -44,16 +42,16 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
private setupBindings() { private setupBindings() {
this.subscription?.unbind(); this.subscription?.unbind();
this.subscription = this.trackUnit.addSubscriber(this, EventTypeSubscriptions); this.subscription = this.trackUnit.addSubscriber(this, EventTypeSubscriptions);
this.hoverListeners.forEach(listener => this.getNode().removeEventListener("mouseover", listener)); this.hoverListeners.forEach(listener => this.render().removeEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.getNode().removeEventListener("mousedown", listener)); this.mouseDownListeners.forEach(listener => this.render().removeEventListener("mousedown", listener));
this.redraw(); this.redraw();
this.hoverListeners.forEach(listener => this.getNode().addEventListener("mouseover", listener)); this.hoverListeners.forEach(listener => this.render().addEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.getNode().addEventListener("mousedown", listener)); this.mouseDownListeners.forEach(listener => this.render().addEventListener("mousedown", listener));
this.getNode().addEventListener("mousedown", (ev) => this.handleMouseDown(ev)); this.render().addEventListener("mousedown", (ev) => this.handleMouseDown(ev));
this.getNode().addEventListener("mouseout", (ev) => this.handleMouseOut(ev)); this.render().addEventListener("mouseout", (ev) => this.handleMouseOut(ev));
this.getNode().addEventListener("mouseup", (ev) => this.handleMouseUp(ev)); this.render().addEventListener("mouseup", (ev) => this.handleMouseUp(ev));
this.getNode().addEventListener("touchstart", (ev) => this.handleTouchStart(ev)); this.render().addEventListener("touchstart", (ev) => this.handleTouchStart(ev));
this.getNode().addEventListener("touchend", (ev) => this.handleTouchEnd(ev)); this.render().addEventListener("touchend", (ev) => this.handleTouchEnd(ev));
} }
private handleMouseDown(ev: MouseEvent): void { private handleMouseDown(ev: MouseEvent): void {
@@ -116,28 +114,28 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
notify(publisher: unknown, event: EventTypeSubscriptions): void { notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) { switch (event) {
case TrackUnitEvent.On: case TrackUnitEvent.On:
this.getNode().classList.add("track-unit-on"); this.render().classList.add("track-unit-on");
break; break;
case TrackUnitEvent.Off: case TrackUnitEvent.Off:
this.getNode().classList.remove("track-unit-on"); this.render().classList.remove("track-unit-on");
break; break;
case TrackUnitEvent.TypeChange: case TrackUnitEvent.TypeChange:
switch (this.trackUnit.getType()) { switch (this.trackUnit.getType()) {
case TrackUnitType.Normal: case TrackUnitType.Normal:
this.getNode().classList.remove("track-unit-ghost"); this.render().classList.remove("track-unit-ghost");
this.getNode().classList.remove("track-unit-accent"); this.render().classList.remove("track-unit-accent");
break; break;
case TrackUnitType.GhostNote: case TrackUnitType.GhostNote:
this.getNode().classList.add("track-unit-ghost"); this.render().classList.add("track-unit-ghost");
this.getNode().classList.remove("track-unit-accent"); this.render().classList.remove("track-unit-accent");
break; break;
case TrackUnitType.Accent: case TrackUnitType.Accent:
this.getNode().classList.remove("track-unit-ghost"); this.render().classList.remove("track-unit-ghost");
this.getNode().classList.add("track-unit-accent"); this.render().classList.add("track-unit-accent");
break; break;
case TrackUnitType.GhostNoteAccent: case TrackUnitType.GhostNoteAccent:
this.getNode().classList.add("track-unit-ghost"); this.render().classList.add("track-unit-ghost");
this.getNode().classList.add("track-unit-accent"); this.render().classList.add("track-unit-accent");
break; break;
} }
break; break;
@@ -149,19 +147,16 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
if (this.trackUnit.isOn()) { if (this.trackUnit.isOn()) {
classes.push("track-unit-on"); classes.push("track-unit-on");
} }
return h("div", { return <div classes={classes} oncontextmenu={() => false} /> as HTMLElement;
classes: classes,
oncontextmenu: () => false,
});
} }
onHover(cb: () => void): void { onHover(cb: () => void): void {
this.hoverListeners.push(cb); this.hoverListeners.push(cb);
this.getNode().addEventListener("mouseover", cb); this.render().addEventListener("mouseover", cb);
} }
onMouseDown(cb: (ev: MouseEvent) => void): void { onMouseDown(cb: (ev: MouseEvent) => void): void {
this.mouseDownListeners.push(cb); this.mouseDownListeners.push(cb);
this.getNode().addEventListener("mousedown", cb); this.render().addEventListener("mousedown", cb);
} }
} }

View File

@@ -1,121 +0,0 @@
import Ref from "@/Ref";
import {ISubscription} from "@/Publisher";
export type UINodeOptions = {
};
type IRenderAttributes<T extends keyof HTMLElementTagNameMap> = Partial<{
[K in keyof HTMLElementTagNameMap[T]]: HTMLElementTagNameMap[T][K] | Ref<HTMLElementTagNameMap[T][K]>
}> & {
classes?: string[],
saveTo?: Ref<HTMLElementTagNameMap[T] | null>,
};
export default abstract class UINode {
protected node: HTMLElement | null = null;
constructor(options: UINodeOptions) { /* dummy */ }
render(): HTMLElement {
if (!this.node) {
this.node = this.build();
}
return this.node;
}
protected getNode(): HTMLElement {
if (!this.node) {
return this.render();
} else {
return this.node;
}
}
redraw(): void {
const oldNode = this.node;
if (!oldNode || !this.node) {
return;
}
const parent = this.node.parentElement;
if (parent) {
this.node = this.build();
parent.replaceChild(this.node, oldNode);
} else {
this.render();
}
}
protected abstract build(): HTMLElement;
}
export function frag(subs?: Node[]): DocumentFragment {
const frag = document.createDocumentFragment();
if (subs) {
attachSubs(frag, subs);
}
return frag;
}
export function q(text: string): Text {
return document.createTextNode(text);
}
export function h<T extends keyof HTMLElementTagNameMap>(type: T, attributes?: IRenderAttributes<T>, subNodes?: (Node | UINode | Ref)[]): HTMLElementTagNameMap[T] {
const element = document.createElement(type);
if (attributes) {
if (attributes.classes) {
element.classList.add(...attributes.classes);
}
if (attributes.saveTo) {
attributes.saveTo.val = element;
}
applyAttributes(element, attributes);
}
if (subNodes) {
attachSubs(element, subNodes);
}
return element;
}
function nodeRefWatcher<T>(newVal: T extends Ref<infer U> ? U : never, textNode: Text, sub: ISubscription): void {
if (!textNode.parentNode) {
sub.unbind();
textNode.remove();
} else {
textNode.replaceWith(newVal?.toString() ?? "null");
}
}
function attachSubs(node: Element | DocumentFragment, subNodes: (Node | UINode | Ref)[]): void {
for (let i = 0; i < subNodes.length; i++) {
const subNode = subNodes[i];
if (subNode instanceof UINode) {
node.append(subNode.render());
} else if (subNode instanceof Ref) {
const textNode = q(subNode.val.toString());
const sub = subNode.watch((newVal) => nodeRefWatcher<Ref>(newVal, textNode, sub));
node.append(textNode);
} else {
node.append(subNode);
}
}
}
function applyAttributes<T extends keyof HTMLElementTagNameMap>(element: HTMLElement, attributes: IRenderAttributes<T>): void {
for (const key in attributes) {
if (Object.prototype.hasOwnProperty.call(attributes, key)) {
const attribute = (attributes as Record<string, unknown>)[key];
if (attribute) {
if (attribute instanceof Ref) {
const attributeAsRef = attribute as Ref;
const elementWithAttributeKey = element as unknown as Record<string, typeof attributeAsRef.val>;
elementWithAttributeKey[key] = attributeAsRef.val;
attribute.watch((newVal) => elementWithAttributeKey[key] = newVal);
} else {
(element as unknown as ({ [key: string]: typeof attribute }))[key] = attribute;
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
import "./ActionButton.css"; import "./ActionButton.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode"; import IconView, { IconName } from "@/ui/Widgets/Icon/IconView";
import IconView, {IconName} from "@/ui/Widgets/Icon/IconView"; import { h, Rung, RungOptions } from "@djledda/ladder";
export type ActionButtonUINodeOptions = UINodeOptions & { export type ActionButtonUINodeOptions = RungOptions & {
type?: "primary" | "secondary", type?: "primary" | "secondary",
onClick?: (event: MouseEvent) => void, onClick?: (event: MouseEvent) => void,
alt?: string, alt?: string,
@@ -15,7 +15,7 @@ export type ActionButtonUINodeOptions = UINodeOptions & {
icon?: never, icon?: never,
}); });
export default class ActionButtonView extends UINode { export default class ActionButtonView extends Rung<HTMLButtonElement> {
private label: string | null = null; private label: string | null = null;
private icon: IconName | null = null; private icon: IconName | null = null;
private buttonElement!: HTMLButtonElement; private buttonElement!: HTMLButtonElement;
@@ -48,17 +48,18 @@ export default class ActionButtonView extends UINode {
} }
protected build(): HTMLButtonElement { protected build(): HTMLButtonElement {
this.buttonElement = h("button", { this.buttonElement = (
classes: ["action-button", `action-button-${this.type}`], <button
onclick: (event: MouseEvent) => this.disabled || this.onClick(event) classes={["action-button", `action-button-${this.type}`]}
}, [ onclick={(event: MouseEvent) => this.disabled || this.onClick(event)}>
this.icon !== null ? new IconView({ {
iconName: this.icon, this.icon ? new IconView({
color: "var(--color-p-light)", iconName: this.icon,
}) : h("span", { color: "var(--color-p-light)",
innerText: this.label ?? "" }) : <span>{this.label ?? ""}</span>
}), }
]); </button>
) as HTMLButtonElement;
if (this.alt) { if (this.alt) {
this.buttonElement.title = this.alt; this.buttonElement.title = this.alt;
} }

View File

@@ -1,64 +0,0 @@
import "./BoolBox.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import Ref from "@/Ref";
export type BoolBoxUINodeOptions = UINodeOptions & {
label?: string,
value?: boolean,
onInput?: (isChecked: boolean) => void,
};
export default class BoolBoxView extends UINode {
private label: string | null;
private labelElement!: HTMLLabelElement;
private checkboxElement!: HTMLInputElement;
private onInput: (isChecked: boolean) => void;
constructor(options: BoolBoxUINodeOptions) {
super(options);
this.label = options.label ?? "";
this.onInput = options.onInput ?? (() => { /* dummy */ });
}
setLabel(newLabel: string | null): void {
if (newLabel !== null) {
this.label = newLabel;
this.labelElement.innerText = newLabel;
this.labelElement.classList.add("visible");
} else {
this.label = newLabel;
this.labelElement.innerText = "";
this.labelElement.classList.remove("visible");
}
}
setValue(isChecked: boolean): void {
this.checkboxElement.checked = isChecked;
}
build(): HTMLDivElement {
this.labelElement = h("label", {
classes: ["bool-box-label"],
onclick: () => {
this.onInput(!this.checkboxElement.checked);
},
});
if (this.label !== null) {
this.labelElement.innerText = this.label;
this.labelElement.classList.add("visible");
}
this.checkboxElement = h("input", {
type: "checkbox",
classes: ["bool-box-checkbox"],
onclick: (event: Event) => {
this.onInput((event.target as HTMLInputElement).checked);
},
});
return h("div", {
classes: ["bool-box"],
},[
this.labelElement,
this.checkboxElement,
]);
}
}

View File

@@ -0,0 +1,58 @@
import "./BoolBox.css";
import { Capsule, h, Rung, RungOptions } from "@djledda/ladder";
export type BoolBoxUINodeOptions = RungOptions & {
label?: string,
value?: boolean,
onInput?: (isChecked: boolean) => void,
};
export default class BoolBoxView extends Rung {
private label: string | null;
private labelElement = Capsule.new<HTMLLabelElement | null>(null);
private checkboxElement = Capsule.new<HTMLInputElement | null>(null);
private onInput: (isChecked: boolean) => void;
constructor(options: BoolBoxUINodeOptions) {
super(options);
this.label = options.label ?? "";
this.onInput = options.onInput ?? (() => { /* dummy */ });
}
setLabel(newLabel: string | null): void {
if (!this.labelElement.val) {
return;
}
if (newLabel !== null) {
this.label = newLabel;
this.labelElement.val.innerText = newLabel;
this.labelElement.val.classList.add("visible");
} else {
this.label = newLabel;
this.labelElement.val.innerText = "";
this.labelElement.val.classList.remove("visible");
}
}
setValue(isChecked: boolean): void {
if (this.checkboxElement.val) {
this.checkboxElement.val.checked = isChecked;
}
}
build(): HTMLDivElement {
return <div className={"bool-box"}>
<label
saveTo={this.labelElement}
classes={this.label ? ["bool-box-label", "visible"] : ["bool-box-label"]}
onclick={() => this.onInput(!this.checkboxElement.val?.checked)}>
{this.label ?? ""}
</label>
<input
type={"checkbox"}
className={"bool-box-checkbox"}
saveTo={this.checkboxElement}
onclick={(event: Event) => this.onInput((event.target as HTMLInputElement).checked)}/>
</div> as HTMLDivElement;
}
}

View File

@@ -1,23 +1,22 @@
import "./Dropdown.css"; import "./Dropdown.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode"; import { Capsule, h, ICapsule, Rung, RungOptions } from "@djledda/ladder";
import Ref, {MaybeRef} from "@/Ref";
export type DropdownViewOption = { export type DropdownViewOption = {
label: string, label: string,
value: string, value: string,
}; };
export type DropdownUINodeOptions = UINodeOptions & { export type DropdownUINodeOptions = RungOptions & {
options: MaybeRef<DropdownViewOption[]>, options: ICapsule<DropdownViewOption[]> | DropdownViewOption[],
}; };
export default class DropdownView extends UINode { export default class DropdownView extends Rung {
private options: Ref<DropdownViewOption[]>; private options: ICapsule<DropdownViewOption[]>;
private select = Ref.new<HTMLSelectElement | null>(null); private select = Capsule.new<HTMLSelectElement | null>(null);
constructor(options: DropdownUINodeOptions) { constructor(options: DropdownUINodeOptions) {
super(options); super(options);
this.options = Ref.new(options.options); this.options = Capsule.new(options.options);
this.options.watch((newVal) => this.updateOptionsFrom(newVal)); this.options.watch((newVal) => this.updateOptionsFrom(newVal));
} }
@@ -32,10 +31,7 @@ export default class DropdownView extends UINode {
children[i].label = newOptions[i].label; children[i].label = newOptions[i].label;
children[i].value = newOptions[i].value; children[i].value = newOptions[i].value;
} else { } else {
children.push(h("option", { children.push(<option label={newOptions[i].label} value={newOptions[i].value} /> as HTMLOptionElement);
label: newOptions[i].label,
value: newOptions[i].value,
}));
} }
} }
if (children.length - newOptions.length > 0) { if (children.length - newOptions.length > 0) {
@@ -45,8 +41,8 @@ export default class DropdownView extends UINode {
} }
protected build(): HTMLSelectElement { protected build(): HTMLSelectElement {
return h("select", { return <select saveTo={this.select}>
saveTo: this.select, {this.options.val.map(opt => <option label={opt.label} />)}
}, this.options.val.map(opt => h("option", {label: opt.label}))); </select> as HTMLSelectElement;
} }
} }

View File

@@ -1,13 +1,13 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import "./EditableTextFieldView.css"; import "./EditableTextFieldView.css";
import { Rung, h, RungOptions } from "@djledda/ladder";
export type EditableTextFieldViewOptions = UINodeOptions & { export type EditableTextFieldViewOptions = RungOptions & {
initialText?: string, initialText?: string,
setter?: (newString: string) => void, setter?: (newString: string) => void,
noEmpty?: boolean, noEmpty?: boolean,
}; };
export default class EditableTextFieldView extends UINode { export default class EditableTextFieldView extends Rung {
private text: string; private text: string;
private titleInput!: HTMLInputElement; private titleInput!: HTMLInputElement;
private setter: (newString: string) => void; private setter: (newString: string) => void;
@@ -30,12 +30,12 @@ export default class EditableTextFieldView extends UINode {
} }
} }
build(): HTMLSpanElement { build(): Node {
this.titleInput = h("input", { this.titleInput = <input
value: this.text, value={this.text}
classes: ["editable-text-field-view"], className={"editable-text-field-view"}
type: "text", type={"text"}
oninput: (event: Event) => { oninput={(event: Event) => {
const input = (event.target as HTMLInputElement).value; const input = (event.target as HTMLInputElement).value;
if (input === "") { if (input === "") {
if (!this.noEmpty) { if (!this.noEmpty) {
@@ -45,27 +45,28 @@ export default class EditableTextFieldView extends UINode {
this.setter(input); this.setter(input);
this.lastNonEmptyInput = input; this.lastNonEmptyInput = input;
} }
}, }}
onblur: (event: FocusEvent) => { onblur={(event: FocusEvent) => {
if ((event.target as HTMLInputElement).value === "") { if ((event.target as HTMLInputElement).value === "") {
this.setText(this.lastNonEmptyInput); this.setText(this.lastNonEmptyInput);
} }
this.titleInput.replaceWith(this.titleDisplay); this.titleInput.replaceWith(this.titleDisplay);
}, }}
onkeyup: (event: KeyboardEvent) => { onkeyup={(event: KeyboardEvent) => {
if (event.key === "Enter") { if (event.key === "Enter") {
(event.target as HTMLInputElement).blur(); (event.target as HTMLInputElement).blur();
} }
}, }}
}); /> as HTMLInputElement;
this.titleDisplay = h("div", {
innerText: this.text, this.titleDisplay = <div
classes: ["editable-text-field-view"], innerText={this.text}
onclick: () => { className={"editable-text-field-view"}
onclick={() => {
this.titleDisplay.replaceWith(this.titleInput); this.titleDisplay.replaceWith(this.titleInput);
this.titleInput.focus(); this.titleInput.focus();
}, }} /> as HTMLDivElement;
});
return this.titleDisplay; return this.titleDisplay;
} }
} }

View File

@@ -1,9 +1,9 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import "./Icon.css"; import "./Icon.css";
import List from "assets/svgs/list.svg"; import List from "assets/svgs/list.svg";
import ArrowClockwise from "assets/svgs/arrow-clockwise.svg"; import ArrowClockwise from "assets/svgs/arrow-clockwise.svg";
import Trash from "assets/svgs/trash.svg"; import Trash from "assets/svgs/trash.svg";
import Snowflake from "assets/svgs/snowflake.svg"; import Snowflake from "assets/svgs/snowflake.svg";
import { h, Rung, RungOptions } from "@djledda/ladder";
const IconUrlMap = { const IconUrlMap = {
arrowClockwise: ArrowClockwise, arrowClockwise: ArrowClockwise,
@@ -14,12 +14,12 @@ const IconUrlMap = {
export type IconName = keyof typeof IconUrlMap; export type IconName = keyof typeof IconUrlMap;
export type IconViewOptions = UINodeOptions & { export type IconViewOptions = RungOptions & {
iconName: IconName, iconName: IconName,
color?: string, color?: string,
}; };
export default class IconView extends UINode { export default class IconView extends Rung {
private iconUrl: string; private iconUrl: string;
private color: string | null; private color: string | null;
@@ -30,9 +30,7 @@ export default class IconView extends UINode {
} }
build(): HTMLSpanElement { build(): HTMLSpanElement {
const icon = h("div", { const icon = <div className={"icon-view"} /> as HTMLDivElement;
classes: ["icon-view"],
});
const colorString = this.color ? `--icon-bg:${this.color}` : ""; const colorString = this.color ? `--icon-bg:${this.color}` : "";
icon.style.cssText = `-webkit-mask-image: url(${this.iconUrl}); mask-image: url(${this.iconUrl});${colorString}`; icon.style.cssText = `-webkit-mask-image: url(${this.iconUrl}); mask-image: url(${this.iconUrl});${colorString}`;
return icon; return icon;

View File

@@ -1,8 +1,7 @@
import UINode, {h, UINodeOptions} from "@/ui/UINode";
import "./NumberInput.css"; import "./NumberInput.css";
import Ref from "@/Ref"; import { Capsule, h, Rung, RungOptions } from "@djledda/ladder";
type NumberInputUINodeOptionsBase = UINodeOptions & { type NumberInputUINodeOptionsBase = RungOptions & {
label?: string, label?: string,
initialValue?: number, initialValue?: number,
labelPosition?: "top" | "left", labelPosition?: "top" | "left",
@@ -26,9 +25,9 @@ type NumberInputUINodeOptionsGetSet = NumberInputUINodeOptionsBase & {
export type NumberInputUINodeOptions = NumberInputUINodeOptionsGetSet | NumberInputUINodeOptionsIncDecInput; export type NumberInputUINodeOptions = NumberInputUINodeOptionsGetSet | NumberInputUINodeOptionsIncDecInput;
export default class NumberInputView extends UINode { export default class NumberInputView extends Rung<HTMLDivElement> {
private labelElement: Ref<HTMLLabelElement | null> = Ref.new<HTMLLabelElement | null>(null); private labelElement = Capsule.new<HTMLLabelElement | null>(null);
private inputElement: Ref<HTMLInputElement | null> = Ref.new<HTMLInputElement | null>(null); private inputElement = Capsule.new<HTMLInputElement | null>(null);
private labelPosition: "top" | "left"; private labelPosition: "top" | "left";
private value: number; private value: number;
private label: string | null; private label: string | null;
@@ -51,24 +50,27 @@ export default class NumberInputView extends UINode {
} }
setLabel(newLabel: string | null): void { setLabel(newLabel: string | null): void {
if (!this.labelElement.val) {
return;
}
if (newLabel !== null) { if (newLabel !== null) {
this.label = newLabel; this.label = newLabel;
this.labelElement.val!.innerText = newLabel; this.labelElement.val.innerText = newLabel;
this.labelElement.val!.classList.add("visible"); this.labelElement.val.classList.add("visible");
} else { } else {
this.label = newLabel; this.label = newLabel;
this.labelElement.val!.innerText = ""; this.labelElement.val.innerText = "";
this.labelElement.val!.classList.remove("visible"); this.labelElement.val.classList.remove("visible");
} }
} }
disable(): void { disable(): void {
this.node?.classList.add("disabled"); this.render().classList.add("disabled");
this.inputElement.val!.disabled = true; this.inputElement.val!.disabled = true;
} }
enable(): void { enable(): void {
this.node?.classList.remove("disabled"); this.render().classList.remove("disabled");
this.inputElement.val!.disabled = false; this.inputElement.val!.disabled = false;
} }
@@ -82,31 +84,29 @@ export default class NumberInputView extends UINode {
if (this.label !== null) { if (this.label !== null) {
labelClasses.push("visible"); labelClasses.push("visible");
} }
return h("div", { return <div className={"number-input"}>
classes: ["number-input"], <label
}, [ classes={labelClasses}
h("label", { saveTo={this.labelElement}>
classes: labelClasses, {this.label ?? ""}
saveTo: this.labelElement, </label>
innerText: this.label ?? "", <button
}), classes={["number-input-button", "number-input-dec"]}
h("button", { onclick={() => {
innerText: "-",
classes: ["number-input-button", "number-input-dec"],
onclick: () => {
if (this.onDecrement) { if (this.onDecrement) {
this.onDecrement(); this.onDecrement();
} else if (this.setter && this.getter) { } else if (this.setter && this.getter) {
this.setter(this.getter() - 1); this.setter(this.getter() - 1);
} }
}, }}>
}), -
h("input", { </button>
type: "number", <input
saveTo: this.inputElement, type={"number"}
classes: ["number-input-input"], saveTo={this.inputElement}
valueAsNumber: this.value, className={"number-input-input"}
onblur: (event: Event) => { valueAsNumber={this.value}
onblur={(event: Event) => {
const input = (event.target as HTMLInputElement).valueAsNumber; const input = (event.target as HTMLInputElement).valueAsNumber;
if (!isNaN(input)) { if (!isNaN(input)) {
if (this.onNewInput) { if (this.onNewInput) {
@@ -115,19 +115,18 @@ export default class NumberInputView extends UINode {
this.setter(input); this.setter(input);
} }
} }
}, }} />
}), <button
h("button", { classes={["number-input-button", "number-input-inc"]}
innerText: "+", onclick={() => {
classes: ["number-input-button", "number-input-inc"],
onclick: () => {
if (this.onIncrement) { if (this.onIncrement) {
this.onIncrement(); this.onIncrement();
} else if (this.setter && this.getter) { } else if (this.setter && this.getter) {
this.setter(this.getter() + 1); this.setter(this.getter() + 1);
} }
}, }}>
}), +
]); </button>
</div> as HTMLDivElement;
} }
} }

View File

@@ -12,7 +12,10 @@
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"],
"assets/*": ["assets/*"] "assets/*": ["assets/*"]
} },
"jsxFactory": "h",
"jsxFragmentFactory": "frag",
"jsx": "react"
}, },
"include": [ "include": [
"./src/**/*", "./src/**/*",

10
vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: {
'@': '/src',
'assets': '/assets',
}
},
});

View File

@@ -1,90 +0,0 @@
const path = require("path");
const webpack = require("webpack");
const config = require("./config.json");
const TerserWebpackPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpackConfig = {
mode: "development",
entry: "./src/main.ts",
plugins: [new webpack.ProgressPlugin(), new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /\.(ts|tsx)$/,
loader: "ts-loader",
include: [path.resolve(__dirname, "src")],
exclude: [/node_modules/],
},
{
test: /.css$/,
use: [{
loader: config.development ? "style-loader" : MiniCssExtractPlugin.loader,
}, {
loader: "css-loader",
options: {
url: true,
sourceMap: true
}
}]
},
{
test: /\.(png|jpe?g|gif|ttf|woff2?|eot|svg)$/i,
use: [
{
loader: "file-loader",
},
],
}
]
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"assets": path.resolve(__dirname, "./assets"),
},
extensions: [".tsx", ".ts", ".js", ".svg", ".ttf"]
},
output: {
filename: "bundle.js",
publicPath: `${config.baseUrl}/static/`,
path: path.resolve(__dirname, "./public/static"),
},
devServer: {
static: {
directory: path.join(__dirname, "./public"),
publicPath: `${config.baseUrl}/`,
},
hot: true,
compress: true,
port: 9000,
},
};
if (!config.development) {
webpackConfig.optimization = {
minimizer: [new TerserWebpackPlugin()],
splitChunks: {
cacheGroups: {
vendors: {
priority: -10,
test: /[\\/]node_modules[\\/]/
}
},
chunks: "async",
minChunks: 1,
minSize: 30000,
name: false
}
};
webpackConfig.mode = "production";
}
module.exports = {...webpackConfig};