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"
],
"rules": {
"object-curly-spacing": [
"error",
"always"
],
"indent": [
"error",
4

View File

@@ -7,11 +7,9 @@
<title>Drum Slayer</title>
<link rel='icon' type='image/png' href='./favicon.png'>
<link rel='stylesheet' href='./static/main.css'>
<script defer src='./static/bundle.js'></script>
</head>
<body>
<div id="app"></div>
<div id="app"></div>
<script type="module" src='/src/main.ts'></script>
</body>
</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",
"license": "ISC",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"@webpack-cli/generators": "^2.3.0",
"css-loader": "^6.2.0",
"eslint": "^7.32.0",
"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"
"@typescript-eslint/eslint-plugin": "^5.26.0",
"@typescript-eslint/parser": "^5.26.0",
"eslint": "^8.16.0",
"typescript": "^4.7.2",
"vite": "^2.9.9"
},
"dependencies": {
"@djledda/ladder": "^1.0.2",
"file-loader": "^6.2.0"
}
}

View File

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

View File

@@ -1,6 +1,5 @@
import Beat, {BeatEvents} from "@/Beat";
import Ref from "@/Ref";
import ISubscriber from "@/Subscriber";
import Beat, { BeatEvents } from "@/Beat";
import { Capsule, ICapsule, ISubscriber } from "@djledda/ladder";
const EventTypeSubscriptions = [
BeatEvents.TimeSigUpChanged,
@@ -15,7 +14,7 @@ type EventTypeSubscriptions = typeof EventTypeSubscriptions[number];
export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
private readonly beats: Beat[];
private activeBeat: Ref<Beat>;
private activeBeat: ICapsule<Beat>;
private onBeatChangeCbs: (() => void)[] = [];
private autoSave: boolean;
private orientation: "horizontal" | "vertical" | null = null;
@@ -27,7 +26,7 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
if (save) {
const serial = JSON.parse(save);
this.beats = [BeatStore.defaultMainBeatGroup()];
this.activeBeat = Ref.new(this.beats[0]);
this.activeBeat = Capsule.new(this.beats[0]);
this.loadFromSave(serial);
if (this.autoSave) {
this.activeBeat.watch(() => this.save("localStorage"), true);
@@ -39,7 +38,7 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
this.beats = [
BeatStore.defaultMainBeatGroup(),
];
this.activeBeat = Ref.new(this.beats[0]);
this.activeBeat = Capsule.new(this.beats[0]);
}
notify(publisher: unknown, event: EventTypeSubscriptions): void {
@@ -53,14 +52,14 @@ export default class BeatStore implements ISubscriber<EventTypeSubscriptions> {
timeSigUp: 8,
};
const mainBeatGroup = new Beat(defaultSettings);
mainBeatGroup.addTrack({name: "LF"});
mainBeatGroup.addTrack({name: "LH"});
mainBeatGroup.addTrack({name: "RH"});
mainBeatGroup.addTrack({name: "RF"});
mainBeatGroup.addTrack({ name: "LF" });
mainBeatGroup.addTrack({ name: "LH" });
mainBeatGroup.addTrack({ name: "RH" });
mainBeatGroup.addTrack({ name: "RF" });
return mainBeatGroup;
}
getActiveBeat(): Ref<Beat> {
getActiveBeat(): ICapsule<Beat> {
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 {IPublisher, Publisher} from "@/Publisher";
import ISubscriber from "@/Subscriber";
import {isPosInt} from "@/utils";
import TrackUnit, { TrackUnitType } from "@/TrackUnit";
import { isPosInt } from "@/utils";
import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
export type TrackInitOptions = {
timeSig?: {
@@ -53,7 +52,7 @@ export default class Track implements IPublisher<TrackEvents> {
constructor(options?: TrackInitOptions) {
this.key = `B-${Track.count}`;
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);
Track.count++;
this.loopLength = options?.loopLength ?? this.timeSigUp * this.barCount;
@@ -113,11 +112,11 @@ export default class Track implements IPublisher<TrackEvents> {
}
setTimeSigUp(timeSigUp: number): void {
this.setTimeSignature({up: timeSigUp});
this.setTimeSignature({ up: timeSigUp });
}
setTimeSigDown(timeSigUp: number): void {
this.setTimeSignature({down: timeSigUp});
this.setTimeSignature({ down: timeSigUp });
}
setBarCount(barCount: number): void {

View File

@@ -1,6 +1,5 @@
import {IPublisher, Publisher} from "./Publisher";
import ISubscriber from "./Subscriber";
import Track from "@/Track";
import { IPublisher, ISubscriber, Publisher } from "@djledda/ladder";
export const enum TrackUnitType {
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",
title: "Drum Slayer",
});
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.appRoot = appRoot;
appNode.appendChild(appRoot.render());
console.log("OK!");

View File

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

View File

@@ -1,13 +1,12 @@
import "./BeatSettings.css";
import UINode, {h, UINodeOptions} from "@/ui/UINode";
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 TrackSettingsView from "@/ui/TrackSettings/TrackSettingsView";
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,
};
@@ -21,7 +20,7 @@ const EventTypeSubscriptions = [
];
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 barCountInput!: NumberInputView;
private timeSigUpInput!: NumberInputView;
@@ -82,13 +81,13 @@ export default class BeatSettingsView extends UINode implements ISubscriber<Even
}
}
if (!this.trackSettingsContainer) {
this.trackSettingsContainer = h("div", {}, this.trackSettingsViews);
this.trackSettingsContainer = <div>{...this.trackSettingsViews}</div> as HTMLDivElement;
} else {
this.trackSettingsContainer.replaceChildren(...this.trackSettingsViews.reverse().map(view => view.render()));
}
}
build(): HTMLElement {
build(): Node {
this.barCountInput = new NumberInputView({
label: "Bars:",
initialValue: this.beat.getBarCount(),
@@ -107,34 +106,23 @@ export default class BeatSettingsView extends UINode implements ISubscriber<Even
onInput: (isChecked: boolean) => this.beat.setIsUsingAutoBeatLength(isChecked),
});
this.remakeBeatSettingsViews();
return h("div", {
classes: ["beat-settings"],
}, [
h("div", {
classes: ["beat-settings-options"],
}, [
h("div", {
classes: ["beat-settings-boxes", "beat-settings-option"],
}, [
this.timeSigUpInput,
]),
h("div", {
classes: ["beat-settings-bar-count", "beat-settings-option"]
,
}, [
this.barCountInput,
]),
h("div", {
classes: ["beat-settings-bar-count", "beat-settings-option"],
}, [
this.autoBeatLengthCheckbox,
]),
new ActionButtonView({
return <div className={"beat-settings"}>
<div className={"beat-settings-options"}>
<div classes={["beat-settings-boxes", "beat-settings-option"]}>
{this.timeSigUpInput}
</div>
<div classes={["beat-settings-bar-count", "beat-settings-option"]}>
{this.barCountInput}
</div>
<div classes={["beat-settings-bar-count", "beat-settings-option"]}>
{this.autoBeatLengthCheckbox}
</div>
{new ActionButtonView({
label: "New Track",
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 ISubscriber from "@/Subscriber";
import Track, { TrackEvents } from "@/Track";
import TrackUnitView from "@/ui/TrackUnit/TrackUnitView";
import "./Track.css";
import {ISubscription} from "@/Publisher";
import Ref from "@/Ref";
import { Capsule, h, ISubscriber, ISubscription, Rung, RungOptions } from "@djledda/ladder";
export type TrackUINodeOptions = UINodeOptions & {
export type TrackUINodeOptions = RungOptions & {
track: Track,
};
@@ -20,9 +17,9 @@ const EventTypeSubscriptions = [
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 title = Ref.new<HTMLHeadingElement | null>(null);
private title = Capsule.new<HTMLHeadingElement | null>(null);
private trackUnitViews: TrackUnitView[] = [];
private trackUnitViewBlock: HTMLElement | 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.setUnit(trackUnit);
} else {
view = new TrackUnitView({trackUnit});
view = new TrackUnitView({ trackUnit });
this.trackUnitViews.push(view);
view.onHover(() => this.onTrackUnitViewHover(view));
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) {
this.trackUnitViewBlock.replaceChildren(...trackUnitNodes);
} else {
this.trackUnitViewBlock = h("div", {
classes: ["track-unit-block"],
}, [
...trackUnitNodes
]);
this.trackUnitViewBlock = <div className={"track-unit-block"}>{...trackUnitNodes}</div> as HTMLDivElement;
}
}
@@ -127,7 +120,7 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
let spacersInserted = false;
while (!spacersInserted) {
i += barLength;
const newSpacer = h("div", {classes: ["track-spacer"]});
const newSpacer = <div className={"track-spacer"} /> as HTMLDivElement;
const leftNeighbour = this.trackUnitViewBlock.children.item(i);
if (leftNeighbour) {
leftNeighbour.insertAdjacentElement("afterend", newSpacer);
@@ -153,20 +146,16 @@ export default class TrackView extends UINode implements ISubscriber<EventTypeSu
if (!this.trackUnitViewBlock) {
throw new Error("Beat unit block setup failed!");
}
return h("div", {
classes: ["track"],
}, [
h("div", {
classes: ["track-main"],
}, [
h("h3", {
innerText: this.track.getName(),
saveTo: this.title,
classes: ["track-title"],
}),
this.trackUnitViewBlock,
]),
]);
return <div className={"track"}>
<div className={"track-main"}>
<h3
innerText={this.track.getName()}
saveTo={this.title}
className={"track-title"}>
</h3>
{this.trackUnitViewBlock}
</div>
</div> as HTMLElement;
}
}

View File

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

View File

@@ -1,10 +1,8 @@
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, { TrackUnitEvent, TrackUnitType } from "@/TrackUnit";
import "./TrackUnit.css";
import { h, IPublisher, ISubscriber, ISubscription, Publisher, Rung, RungOptions } from "@djledda/ladder";
export type TrackUnitUINodeOptions = UINodeOptions & {
export type TrackUnitUINodeOptions = RungOptions & {
trackUnit: TrackUnit,
};
@@ -15,7 +13,7 @@ const EventTypeSubscriptions = [
];
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 subscription: ISubscription | null = null;
private publisher: IPublisher<TrackUnitEvent> = new Publisher<TrackUnitEvent, TrackUnitView>(this);
@@ -44,16 +42,16 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
private setupBindings() {
this.subscription?.unbind();
this.subscription = this.trackUnit.addSubscriber(this, EventTypeSubscriptions);
this.hoverListeners.forEach(listener => this.getNode().removeEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.getNode().removeEventListener("mousedown", listener));
this.hoverListeners.forEach(listener => this.render().removeEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.render().removeEventListener("mousedown", listener));
this.redraw();
this.hoverListeners.forEach(listener => this.getNode().addEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.getNode().addEventListener("mousedown", listener));
this.getNode().addEventListener("mousedown", (ev) => this.handleMouseDown(ev));
this.getNode().addEventListener("mouseout", (ev) => this.handleMouseOut(ev));
this.getNode().addEventListener("mouseup", (ev) => this.handleMouseUp(ev));
this.getNode().addEventListener("touchstart", (ev) => this.handleTouchStart(ev));
this.getNode().addEventListener("touchend", (ev) => this.handleTouchEnd(ev));
this.hoverListeners.forEach(listener => this.render().addEventListener("mouseover", listener));
this.mouseDownListeners.forEach(listener => this.render().addEventListener("mousedown", listener));
this.render().addEventListener("mousedown", (ev) => this.handleMouseDown(ev));
this.render().addEventListener("mouseout", (ev) => this.handleMouseOut(ev));
this.render().addEventListener("mouseup", (ev) => this.handleMouseUp(ev));
this.render().addEventListener("touchstart", (ev) => this.handleTouchStart(ev));
this.render().addEventListener("touchend", (ev) => this.handleTouchEnd(ev));
}
private handleMouseDown(ev: MouseEvent): void {
@@ -116,28 +114,28 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
notify(publisher: unknown, event: EventTypeSubscriptions): void {
switch (event) {
case TrackUnitEvent.On:
this.getNode().classList.add("track-unit-on");
this.render().classList.add("track-unit-on");
break;
case TrackUnitEvent.Off:
this.getNode().classList.remove("track-unit-on");
this.render().classList.remove("track-unit-on");
break;
case TrackUnitEvent.TypeChange:
switch (this.trackUnit.getType()) {
case TrackUnitType.Normal:
this.getNode().classList.remove("track-unit-ghost");
this.getNode().classList.remove("track-unit-accent");
this.render().classList.remove("track-unit-ghost");
this.render().classList.remove("track-unit-accent");
break;
case TrackUnitType.GhostNote:
this.getNode().classList.add("track-unit-ghost");
this.getNode().classList.remove("track-unit-accent");
this.render().classList.add("track-unit-ghost");
this.render().classList.remove("track-unit-accent");
break;
case TrackUnitType.Accent:
this.getNode().classList.remove("track-unit-ghost");
this.getNode().classList.add("track-unit-accent");
this.render().classList.remove("track-unit-ghost");
this.render().classList.add("track-unit-accent");
break;
case TrackUnitType.GhostNoteAccent:
this.getNode().classList.add("track-unit-ghost");
this.getNode().classList.add("track-unit-accent");
this.render().classList.add("track-unit-ghost");
this.render().classList.add("track-unit-accent");
break;
}
break;
@@ -149,19 +147,16 @@ export default class TrackUnitView extends UINode implements ISubscriber<EventTy
if (this.trackUnit.isOn()) {
classes.push("track-unit-on");
}
return h("div", {
classes: classes,
oncontextmenu: () => false,
});
return <div classes={classes} oncontextmenu={() => false} /> as HTMLElement;
}
onHover(cb: () => void): void {
this.hoverListeners.push(cb);
this.getNode().addEventListener("mouseover", cb);
this.render().addEventListener("mouseover", cb);
}
onMouseDown(cb: (ev: MouseEvent) => void): void {
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 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",
onClick?: (event: MouseEvent) => void,
alt?: string,
@@ -15,7 +15,7 @@ export type ActionButtonUINodeOptions = UINodeOptions & {
icon?: never,
});
export default class ActionButtonView extends UINode {
export default class ActionButtonView extends Rung<HTMLButtonElement> {
private label: string | null = null;
private icon: IconName | null = null;
private buttonElement!: HTMLButtonElement;
@@ -48,17 +48,18 @@ export default class ActionButtonView extends UINode {
}
protected build(): HTMLButtonElement {
this.buttonElement = h("button", {
classes: ["action-button", `action-button-${this.type}`],
onclick: (event: MouseEvent) => this.disabled || this.onClick(event)
}, [
this.icon !== null ? new IconView({
iconName: this.icon,
color: "var(--color-p-light)",
}) : h("span", {
innerText: this.label ?? ""
}),
]);
this.buttonElement = (
<button
classes={["action-button", `action-button-${this.type}`]}
onclick={(event: MouseEvent) => this.disabled || this.onClick(event)}>
{
this.icon ? new IconView({
iconName: this.icon,
color: "var(--color-p-light)",
}) : <span>{this.label ?? ""}</span>
}
</button>
) as HTMLButtonElement;
if (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 UINode, {h, UINodeOptions} from "@/ui/UINode";
import Ref, {MaybeRef} from "@/Ref";
import { Capsule, h, ICapsule, Rung, RungOptions } from "@djledda/ladder";
export type DropdownViewOption = {
label: string,
value: string,
};
export type DropdownUINodeOptions = UINodeOptions & {
options: MaybeRef<DropdownViewOption[]>,
export type DropdownUINodeOptions = RungOptions & {
options: ICapsule<DropdownViewOption[]> | DropdownViewOption[],
};
export default class DropdownView extends UINode {
private options: Ref<DropdownViewOption[]>;
private select = Ref.new<HTMLSelectElement | null>(null);
export default class DropdownView extends Rung {
private options: ICapsule<DropdownViewOption[]>;
private select = Capsule.new<HTMLSelectElement | null>(null);
constructor(options: DropdownUINodeOptions) {
super(options);
this.options = Ref.new(options.options);
this.options = Capsule.new(options.options);
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].value = newOptions[i].value;
} else {
children.push(h("option", {
label: newOptions[i].label,
value: newOptions[i].value,
}));
children.push(<option label={newOptions[i].label} value={newOptions[i].value} /> as HTMLOptionElement);
}
}
if (children.length - newOptions.length > 0) {
@@ -45,8 +41,8 @@ export default class DropdownView extends UINode {
}
protected build(): HTMLSelectElement {
return h("select", {
saveTo: this.select,
}, this.options.val.map(opt => h("option", {label: opt.label})));
return <select saveTo={this.select}>
{this.options.val.map(opt => <option label={opt.label} />)}
</select> as HTMLSelectElement;
}
}

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,10 @@
"paths": {
"@/*": ["src/*"],
"assets/*": ["assets/*"]
}
},
"jsxFactory": "h",
"jsxFragmentFactory": "frag",
"jsx": "react"
},
"include": [
"./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};