Initial move

This commit is contained in:
2024-09-03 14:50:33 +02:00
parent adb6be0006
commit 9fa656ed5e
138 changed files with 13117 additions and 0 deletions

View File

@@ -0,0 +1,76 @@
import { UINode } from "./node";
export class AudioRecorder extends UINode {
private audioElement: HTMLAudioElement;
private mediaRecorder: MediaRecorder | null;
private audioChunks: Blob[];
private stream: MediaStream | null;
private recording?: Blob;
public constructor(title: string) {
super(title);
this.audioElement = document.createElement("audio");
this.mediaRecorder = null;
this.audioChunks = [];
this.stream = null;
this.audioElement.setAttribute("controls", "true");
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio-recorder");
}
public async startRecording() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: { autoGainControl: true, channelCount: 2, echoCancellation: false, noiseSuppression: false } });
this.mediaRecorder = new MediaRecorder(this.stream);
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
this.mediaRecorder.onstop = () => {
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' });
this.recording = audioBlob;
this.audioChunks = [];
const audioUrl = URL.createObjectURL(audioBlob);
this.audioElement.src = audioUrl;
this.triggerRecordingComplete(audioUrl);
};
this.mediaRecorder.start();
} catch (error) {
console.error("Error accessing microphone:", error);
}
}
public stopRecording() {
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
this.mediaRecorder.stop();
}
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
}
public getElement(): HTMLElement {
return this.element;
}
public onRecordingComplete(callback: (audioUrl: string) => void) {
this.element.addEventListener("recording-complete", (event: Event) => {
const customEvent = event as CustomEvent;
callback(customEvent.detail.audioUrl);
});
return this;
}
protected triggerRecordingComplete(audioUrl: string) {
const event = new CustomEvent("recording-complete", { detail: { audioUrl } });
this.element.dispatchEvent(event);
return this;
}
public getRecording() {
return this.recording;
}
}

66
frontend/src/ui/audio.ts Normal file
View File

@@ -0,0 +1,66 @@
import { UINode } from "./node";
export class Audio extends UINode {
private audioElement: HTMLAudioElement;
public constructor(title: string, src: string | MediaStream = "") {
super(title);
this.audioElement = document.createElement("audio");
if (typeof src === "string") {
this.audioElement.src = src; // Set src if it's a string URL
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src; // Set srcObject if it's a MediaStream
}
this.audioElement.setAttribute("aria-label", title);
this.element.appendChild(this.audioElement);
this.setRole("audio");
}
public getElement(): HTMLElement {
return this.audioElement;
}
public setSource(src: string | MediaStream) {
if (typeof src === "string") {
this.audioElement.src = src;
} else if (src instanceof MediaStream) {
this.audioElement.srcObject = src;
}
return this;
}
public play() {
this.audioElement.play();
return this;
}
public pause() {
this.audioElement.pause();
return this;
}
public setControls(show: boolean) {
this.audioElement.controls = show;
return this;
}
public setLoop(loop: boolean) {
this.audioElement.loop = loop;
return this;
}
public setMuted(muted: boolean) {
this.audioElement.muted = muted;
return this;
}
public setAutoplay(autoplay: boolean) {
this.audioElement.autoplay = autoplay;
return this;
}
public setVolume(volume: number) {
this.audioElement.volume = volume;
return this;
}
}

39
frontend/src/ui/button.ts Normal file
View File

@@ -0,0 +1,39 @@
import { UINode } from "./node";
export class Button extends UINode {
private buttonElement: HTMLButtonElement;
public constructor(title: string, hasPopup: boolean = false) {
super(title);
this.buttonElement = document.createElement("button");
this.buttonElement.innerText = title;
if (hasPopup) this.buttonElement.setAttribute("aria-haspopup", "true");
this.element.appendChild(this.buttonElement);
this.element.setAttribute("aria-label", this.title);
}
public focus() {
this.buttonElement.focus();
return this;
}
public click() {
this.buttonElement.click();
return this;
}
public getElement(): HTMLElement {
return this.buttonElement;
}
public setText(text: string) {
this.title = text;
this.buttonElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
return this;
}
public setDisabled(val: boolean) {
this.buttonElement.disabled = val;
return this;
}
}

77
frontend/src/ui/camera.ts Normal file
View File

@@ -0,0 +1,77 @@
import { UINode } from "./node";
export class Camera extends UINode {
private videoElement: HTMLVideoElement;
private canvasElement: HTMLCanvasElement;
private stream: MediaStream | null;
public constructor(title: string) {
super(title);
this.videoElement = document.createElement("video");
this.canvasElement = document.createElement("canvas");
this.stream = null;
this.videoElement.setAttribute("aria-label", title);
this.element.appendChild(this.videoElement);
this.element.appendChild(this.canvasElement);
this.setRole("camera");
}
public async startCamera() {
try {
this.stream = await navigator.mediaDevices.getUserMedia({ video: true });
this.videoElement.srcObject = this.stream;
this.videoElement.play();
} catch (error) {
console.error("Error accessing camera:", error);
}
}
public stopCamera() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.videoElement.pause();
this.videoElement.srcObject = null;
}
public takePhoto(): HTMLCanvasElement | null {
if (this.stream) {
const context = this.canvasElement.getContext("2d");
if (context) {
this.canvasElement.width = this.videoElement.videoWidth;
this.canvasElement.height = this.videoElement.videoHeight;
context.drawImage(this.videoElement, 0, 0, this.canvasElement.width, this.canvasElement.height);
return this.canvasElement;
}
}
return null;
}
public savePhoto(): string | null {
const photoCanvas = this.takePhoto();
if (photoCanvas) {
return photoCanvas.toDataURL("image/png");
}
return null;
}
public savePhotoToBlob(): Promise<Blob | null> {
return new Promise((resolve) => {
const photoCanvas = this.takePhoto();
if (photoCanvas) {
photoCanvas.toBlob((blob) => {
resolve(blob);
});
} else {
resolve(null);
}
});
}
public getElement(): HTMLElement {
return this.element;
}
}

26
frontend/src/ui/canvas.ts Normal file
View File

@@ -0,0 +1,26 @@
import { UINode } from "./node";
export class Canvas extends UINode {
private canvasElement: HTMLCanvasElement;
public constructor(title: string) {
super(title);
this.canvasElement = document.createElement("canvas");
this.canvasElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.canvasElement);
}
public focus() {
this.canvasElement.focus();
return this;
}
public click() {
this.canvasElement.click();
return this;
}
public getElement(): HTMLElement {
return this.canvasElement;
}
}

View File

@@ -0,0 +1,50 @@
import { UINode } from "./node";
export class Checkbox extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private checkboxElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.id = `chkbx_title_${this.id}`;
this.checkboxElement = document.createElement("input");
this.checkboxElement.id = `chkbx_${this.id}`;
this.checkboxElement.type = "checkbox";
this.titleElement.appendChild(this.checkboxElement);
this.titleElement.appendChild(document.createTextNode(this.title));
this.element.appendChild(this.titleElement);
}
public focus() {
this.checkboxElement.focus();
return this;
}
public click() {
this.checkboxElement.click();
return this;
}
public getElement(): HTMLElement {
return this.checkboxElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.element.setAttribute("aria-roledescription", "checkbox");
return this;
}
public isChecked(): boolean {
return this.checkboxElement.checked;
}
public setChecked(value: boolean) {
this.checkboxElement.checked = value;
return this;
}
}

View File

@@ -0,0 +1,42 @@
import { Container } from "./container";
export class CollapsableContainer extends Container {
private detailsElement: HTMLDetailsElement;
private summaryElement: HTMLElement;
private wrapperElement: HTMLDivElement;
public constructor(title: string) {
super(title);
this.wrapperElement = document.createElement("div");
this.detailsElement = document.createElement("details");
this.summaryElement = document.createElement("summary");
this.summaryElement.innerText = title;
this.detailsElement.appendChild(this.summaryElement);
this.detailsElement.appendChild(this.containerElement);
this.wrapperElement.appendChild(this.detailsElement);
}
public render() {
return this.wrapperElement;
}
public setTitle(text: string) {
this.title = text;
this.summaryElement.innerText = text;
return this;
}
public isCollapsed(): boolean {
return this.detailsElement.hasAttribute("open");
}
public expand(val: boolean) {
if (val) {
this.detailsElement.setAttribute("open", "true");
} else {
this.detailsElement.removeAttribute("open");
}
return this;
}
}

View File

@@ -0,0 +1,55 @@
import { UINode } from "./node";
export class Container extends UINode {
public children: UINode[];
protected containerElement: HTMLDivElement;
private focused: number = 0;
public constructor(title: string) {
super(title);
this.children = [];
this.containerElement = document.createElement("div");
this.containerElement.setAttribute("tabindex", "-1");
this.focused = 0;
}
public focus() {
this.containerElement.focus();
return this;
}
public _onFocus() {
this.children[this.focused].focus();
}
public add(node: UINode) {
this.children.push(node);
node._onConnect();
this.containerElement.appendChild(node.render());
return this;
}
public remove(node: UINode) {
this.children.splice(this.children.indexOf(node), 1);
node._onDisconnect();
this.containerElement.removeChild(node.render());
return this;
}
public render() {
return this.containerElement;
}
public getChildren(): UINode[] {
return this.children;
}
public getElement() {
return this.containerElement;
}
public setAriaLabel(text: string) {
this.containerElement.setAttribute("aria-label", text);
return this;
}
}

View File

@@ -0,0 +1,44 @@
import { UINode } from "./node";
export class DatePicker extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `datepicker_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `datepicker_${this.id}`;
this.inputElement.type = "date";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
return this;
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
return this;
}
}

78
frontend/src/ui/dialog.ts Normal file
View File

@@ -0,0 +1,78 @@
import { UIWindow } from "./window";
import { Button } from "./button";
export class Dialog<T> extends UIWindow {
private resolvePromise!: (value: T | PromiseLike<T>) => void;
private rejectPromise!: (reason?: any) => void;
private promise: Promise<T>;
private dialogElement!: HTMLDialogElement;
protected okButton?: Button;
protected cancelButton?: Button;
private previouslyFocusedElement!: HTMLElement;
public constructor(title: string, addButtons: boolean = true) {
super(title, "dialog", false);
this.dialogElement = document.createElement("dialog");
this.promise = new Promise<T>((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
// Automatically add OK and Cancel buttons
if (addButtons) {
this.okButton = new Button("OK");
this.okButton.setPosition(70, 90, 10, 5);
this.okButton.onClick(() => this.choose(undefined));
this.cancelButton = new Button("Cancel");
this.cancelButton.setPosition(20, 90, 10, 5);
this.cancelButton.onClick(() => this.cancel());
}
}
public setOkAction(action: () => T) {
if (!this.okButton) return;
this.okButton.onClick(() => {
const result = action();
this.choose(result);
});
return this;
}
public setCancelAction(action: () => void) {
if (!this.cancelButton) return;
this.cancelButton.onClick(() => {
action();
this.cancel();
});
return this;
}
public choose(item: T | undefined) {
this.resolvePromise(item as T);
document.body.removeChild(this.dialogElement);
this.hide();
this.previouslyFocusedElement.focus();
}
public cancel(reason?: any) {
this.rejectPromise(reason);
document.body.removeChild(this.dialogElement);
this.hide();
this.previouslyFocusedElement.focus();
}
public open(): Promise<T> {
this.previouslyFocusedElement = document.activeElement as HTMLElement;
this.dialogElement.appendChild(this.show()!);
if (this.okButton) this.add(this.okButton);
if (this.cancelButton) this.add(this.cancelButton);
document.body.appendChild(this.dialogElement);
this.dialogElement.showModal();
this.container.focus();
return this.promise;
}
}

View File

@@ -0,0 +1,77 @@
import { UINode } from "./node";
export class Dropdown extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private selectElement: HTMLSelectElement;
public constructor(title: string, options: { key: string; value: string }[]) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `dd_title_${this.id}`;
this.selectElement = document.createElement("select");
this.selectElement.id = `dd_${this.id}`;
this.titleElement.appendChild(this.selectElement);
this.element.appendChild(this.titleElement);
this.setOptions(options);
}
public focus() {
this.selectElement.focus();
return this;
}
public getElement(): HTMLElement {
return this.selectElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getSelectedValue(): string {
return this.selectElement.value;
}
public setSelectedValue(value: string) {
this.selectElement.value = value;
return this;
}
public setOptions(options: { key: string; value: string }[]) {
this.clearOptions();
options.forEach((option) => {
this.addOption(option.key, option.value);
});
return this;
}
public addOption(key: string, value: string) {
const optionElement = document.createElement("option");
optionElement.value = key;
optionElement.innerText = value;
this.selectElement.appendChild(optionElement);
return this;
}
public removeOption(key: string) {
const options = Array.from(this.selectElement.options);
const optionToRemove = options.find(option => option.value === key);
if (optionToRemove) {
this.selectElement.removeChild(optionToRemove);
}
return this;
}
public clearOptions() {
while (this.selectElement.firstChild) {
this.selectElement.removeChild(this.selectElement.firstChild);
}
return this
}
}

View File

@@ -0,0 +1,47 @@
import { UINode } from "./node";
export class FileInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string, multiple: boolean = false) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `fileinpt_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `fileinpt_${this.id}`;
this.inputElement.type = "file";
if (multiple) {
this.inputElement.multiple = true;
}
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
return this;
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getFiles(): FileList | null {
return this.inputElement.files;
}
public setAccept(accept: string) {
this.inputElement.accept = accept;
return this;
}
}

33
frontend/src/ui/image.ts Normal file
View File

@@ -0,0 +1,33 @@
import { UINode } from "./node";
export class Image extends UINode {
private imgElement: HTMLImageElement;
public constructor(title: string, src: string, altText: string = "") {
super(title);
this.imgElement = document.createElement("img");
this.imgElement.src = src;
this.imgElement.alt = altText;
this.element.appendChild(this.imgElement);
this.element.setAttribute("aria-label", title);
}
public getElement(): HTMLElement {
return this.imgElement;
}
public setText(text: string) {
this.title = text;
this.element.setAttribute("aria-label", text);
return this;
}
public setSource(src: string) {
this.imgElement.src = src;
return this;
}
public setAltText(altText: string) {
this.imgElement.alt = altText;
return this;
}
}

12
frontend/src/ui/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { UIWindow } from "./window";
export { Button } from "./button";
export { Container } from "./container";
export { UINode } from "./node";
export { List } from "./list";
export { Text } from "./text";
export { ListItem } from "./list-item";
export { Checkbox } from "./checkbox";
export { TextInput } from "./text-input";
export { TabBar } from "./tab-bar";
export { TabbedView } from "./tabbed-view";
export { Canvas } from "./canvas";

View File

@@ -0,0 +1,37 @@
import { UINode } from "./node";
export class ListItem extends UINode {
private listElement: HTMLLIElement;
public constructor(title: string) {
super(title);
this.listElement = document.createElement("li");
this.listElement.innerText = this.title;
this.listElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.listElement);
this.listElement.setAttribute("aria-label", this.title);
this.listElement.setAttribute("role", "option");
}
public focus() {
this.listElement.focus();
return this;
}
public click() {
this.listElement.click();
return this;
}
public getElement(): HTMLElement {
return this.listElement;
}
public setText(text: string) {
this.title = text;
this.listElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.listElement.setAttribute("aria-label", this.title);
return this;
}
}

171
frontend/src/ui/list.ts Normal file
View File

@@ -0,0 +1,171 @@
import { UINode } from "./node";
export class List extends UINode {
public children: UINode[];
protected listElement: HTMLUListElement;
private focused: number;
protected selectCallback?: (id: number) => void;
public constructor(title: string) {
super(title);
this.children = [];
this.listElement = document.createElement("ul");
this.listElement.setAttribute("role", "listbox");
this.listElement.style.listStyle = "none";
this.element.appendChild(this.listElement);
this.element.setAttribute("aria-label", this.title);
this.focused = 0;
}
public add(node: UINode) {
this.children.push(node);
node._onConnect();
this.listElement.appendChild(node.render());
if (this.children.length === 1) this.calculateTabIndex();
node.onFocus(() => this.calculateFocused(node));
return this;
}
public addNodeAtIndex(node: UINode, index: number) {
index = Math.max(0, Math.min(index, this.children.length));
this.children.splice(index, 0, node);
node._onConnect();
this.listElement.insertBefore(node.render(), this.listElement.children[index]);
if (this.children.length === 1) this.calculateTabIndex();
node.onFocus(() => this.calculateFocused(node));
return this;
}
public remove(node: UINode) {
const idx = this.children.indexOf(node);
this.children.splice(idx, 1);
node._onDisconnect();
this.listElement.removeChild(node.render());
if (idx === this.focused) {
if (this.focused > 0) this.focused--;
this.calculateTabIndex();
}
return this;
}
public _onFocus() {
super._onFocus();
this.children[this.focused].focus();
return this;
}
public _onClick() {
// this.children[this.focused]._onClick();
}
public _onSelect(id: number) {
if (this.selectCallback) this.selectCallback(id);
}
protected calculateStyle(): void {
super.calculateStyle();
this.element.style.overflowY = "scroll";
this.listElement.style.overflowY = "scroll";
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
switch (key) {
case "ArrowUp":
this.children[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
case "ArrowDown":
this.children[this.focused].setTabbable(false);
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
case "Enter":
this.children[this.focused].click();
return true;
break;
case "Home":
this.children[this.focused].setTabbable(false);
this.focused = 0;
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
case "End":
this.children[this.focused].setTabbable(false);
this.focused = this.children.length - 1;
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
break;
default:
return this.children[this.focused]._onKeydown(key);
break;
}
return false;
}
protected renderAsListItem(node: UINode) {
let li = document.createElement("li");
li.appendChild(node.render());
return li;
}
public getElement(): HTMLElement {
return this.listElement;
}
public isItemFocused(): boolean {
const has = this.children.find((child) => child.isFocused);
if (has) {
return true;
}
return false;
}
private calculateTabIndex() {
if (this.children.length < 1) return;
this.children[this.focused].setTabbable(true);
}
public clear() {
this.children.forEach((child) => this.remove(child));
this.children = [];
this.listElement.innerHTML = '';
this.focused = 0;
return this;
}
public getFocusedChild() {
return this.children[this.focused];
}
public getFocus() {
return this.focused;
}
public onSelect(f: (id: number) => void) {
this.selectCallback = f;
return this;
}
protected calculateFocused(node: UINode) {
const idx = this.children.indexOf(node);
this._onSelect(idx);
this.focused = idx;
}
public scrollToBottom() {
this.children.forEach((child) => child.setTabbable(false));
const node = this.children[this.children.length - 1];
node.getElement().scrollIntoView();
// set the focused element for tab index without focusing directly.
this.focused = this.children.length - 1;
this.children[this.focused].setTabbable(true);
return this;
}
}

View File

@@ -0,0 +1,47 @@
import { UINode } from "./node";
export class MultilineInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private textareaElement: HTMLTextAreaElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `txtarea_title_${this.id}`;
this.textareaElement = document.createElement("textarea");
this.textareaElement.id = `txtarea_${this.id}`;
this.titleElement.appendChild(this.textareaElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.textareaElement.focus();
return this;
}
public click() {
this.textareaElement.click();
return this;
}
public getElement(): HTMLElement {
return this.textareaElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getValue(): string {
return this.textareaElement.value;
}
public setValue(value: string) {
this.textareaElement.value = value;
return this;
}
}

161
frontend/src/ui/node.ts Normal file
View File

@@ -0,0 +1,161 @@
import { UITab } from "./tab";
export class UINode {
protected title: string;
protected element: HTMLDivElement;
protected position!: {x: number, y: number, width: number, height: number};
protected positionType: string = "fixed";
protected calculateOwnStyle: boolean = true;
protected keyDownCallback!: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void | undefined;
protected focusCallback?: () => void;
protected blurCallback?: () => void;
protected clickCallback?: () => void;
protected globalKeydown: boolean = false;
protected visible: boolean;
public isFocused: boolean;
private userdata: any;
public constructor(title: string) {
this.title = title;
this.element = document.createElement("div");
this.element.setAttribute("tabindex", "-1");
this.visible = false;
this.isFocused = false;
}
public focus() {
this.element.focus();
}
public click() {
this.element.click();
}
public _onConnect() {
this.calculateStyle();
this.addListeners();
return;
}
public _onDisconnect() {
return;
}
public _onFocus() {
if (this.focusCallback) this.focusCallback();
this.isFocused = true;
return;
}
public _onBlur() {
if (this.blurCallback) this.blurCallback();
this.isFocused = false;
return;
}
public _onClick() {
if (this.clickCallback) this.clickCallback();
return;
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
if (this.keyDownCallback) {
if (this.globalKeydown || (!this.globalKeydown && document.activeElement === this.getElement())) {
this.keyDownCallback(key, alt, shift, ctrl);
return true;
}
}
return false;
}
public render(): HTMLElement {
this.visible = true;
return this.element;
}
protected addListeners() {
const elem = this.element;
this.getElement().addEventListener("focus", (e) => this._onFocus());
elem.addEventListener("blur", (e) => this._onBlur());
elem.addEventListener("click", (e) => this._onClick());
elem.addEventListener("keydown", e => this._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey));
}
protected calculateStyle() {
if (!this.calculateOwnStyle || !this.position) return;
this.element.style.position = this.positionType;
this.element.style.left = `${this.position.x}%`;
this.element.style.top = `${this.position.y}%`;
this.element.style.width = `${this.position.width}%`;
this.element.style.height = `${this.position.height}%`;
}
public setPosition(x: number, y: number, width: number, height: number, type: string = "fixed") {
this.position = {
x: x,
y: y,
width: width,
height: height,
};
this.positionType = type;
this.calculateOwnStyle = true;
this.calculateStyle();
return this;
}
public onClick(f: () => void) {
this.clickCallback = f;
return this;
}
public onFocus(f: () => void) {
this.focusCallback = f;
return this;
}
public onKeyDown(f: (key: string, alt?: boolean, shift?: boolean, ctrl?: boolean) => void, global: boolean = false) {
this.keyDownCallback = f;
this.globalKeydown = global;
return this;
}
public onBlur(f: () => void) {
this.blurCallback = f;
return this;
}
public getElement(): HTMLElement {
return this.element;
}
public setTabbable(val: boolean) {
this.getElement().setAttribute("tabindex",
(val === true) ? "0" :
"-1");
return this;
}
public setAriaLabel(text: string) {
this.element.setAttribute("aria-label", text);
return this;
}
public setRole(role: string) {
this.getElement().setAttribute("role", role);
return this;
}
public getUserData(): any {
return this.userdata;
}
public setUserData(obj: any) {
this.userdata = obj;
return this;
}
public setAccessKey(key: string) {
this.getElement().accessKey = key;
return this;
}
}

View File

@@ -0,0 +1,40 @@
import { UINode } from "./node";
export class ProgressBar extends UINode {
private progressElement: HTMLProgressElement;
public constructor(title: string, max: number) {
super(title);
this.progressElement = document.createElement("progress");
this.progressElement.max = max;
this.element.appendChild(this.progressElement);
this.element.setAttribute("aria-label", title);
}
public getElement(): HTMLElement {
return this.progressElement;
}
public setText(text: string) {
this.title = text;
this.element.setAttribute("aria-label", text);
return this;
}
public getValue(): number {
return this.progressElement.value;
}
public setValue(value: number) {
this.progressElement.value = value;
return this;
}
public getMax(): number {
return this.progressElement.max;
}
public setMax(max: number) {
this.progressElement.max = max;
return this;
}
}

View File

@@ -0,0 +1,79 @@
import { UINode } from "./node";
export class RadioGroup extends UINode {
private id: string;
private titleElement: HTMLLegendElement;
private containerElement: HTMLFieldSetElement;
private radioElements: Map<string, HTMLInputElement>;
private radioLabels: Map<string, HTMLLabelElement>;
public constructor(title: string, options: { key: string; value: string }[]) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("legend");
this.titleElement.innerText = title;
this.titleElement.id = `rdgrp_title_${this.id}`;
this.containerElement = document.createElement("fieldset");
this.containerElement.appendChild(this.titleElement);
this.element.appendChild(this.containerElement);
this.radioElements = new Map();
this.radioLabels = new Map();
options.forEach((option) => {
const radioId = `rd_${this.id}_${option.key}`;
const radioElement = document.createElement("input");
radioElement.id = radioId;
radioElement.type = "radio";
radioElement.name = `rdgrp_${this.id}`;
radioElement.value = option.key;
radioElement.setAttribute("aria-labeledby", `${radioId}_label`);
const radioLabel = document.createElement("label");
radioLabel.innerText = option.value;
radioLabel.id = `${radioId}_label`;
radioLabel.setAttribute("for", radioId);
this.radioElements.set(option.key, radioElement);
this.radioLabels.set(option.key, radioLabel);
this.containerElement.appendChild(radioElement);
this.containerElement.appendChild(radioLabel);
});
}
public focus() {
const firstRadioElement = this.radioElements.values().next().value;
if (firstRadioElement) {
firstRadioElement.focus();
}
return this;
}
public getElement(): HTMLElement {
return this.containerElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getSelectedValue(): string | null {
for (const [key, radioElement] of this.radioElements.entries()) {
if (radioElement.checked) {
return key;
}
}
return null;
}
public setSelectedValue(value: string) {
const radioElement = this.radioElements.get(value);
if (radioElement) {
radioElement.checked = true;
}
return this;
}
}

51
frontend/src/ui/slider.ts Normal file
View File

@@ -0,0 +1,51 @@
import { UINode } from "./node";
export class Slider extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private sliderElement: HTMLInputElement;
public constructor(title: string, min: number, max: number, step: number = 1) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `sldr_title_${this.id}`;
this.sliderElement = document.createElement("input");
this.sliderElement.id = `sldr_${this.id}`;
this.sliderElement.type = "range";
this.sliderElement.min = min.toString();
this.sliderElement.max = max.toString();
this.sliderElement.step = step.toString();
this.titleElement.appendChild(this.sliderElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.sliderElement.focus();
return this;
}
public click() {
this.sliderElement.click();
return this;
}
public getElement(): HTMLElement {
return this.sliderElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getValue(): number {
return parseInt(this.sliderElement.value);
}
public setValue(value: number) {
this.sliderElement.value = value.toString();
return this;
}
}

102
frontend/src/ui/tab-bar.ts Normal file
View File

@@ -0,0 +1,102 @@
import { UINode } from "./node";
import { UITab } from "./tab";
export class TabBar extends UINode {
private tabs: UITab[];
private tabBarContainer: HTMLDivElement;
private onTabChangeCallback?: (index: number) => void;
private focused: number;
public constructor(title: string = "tab bar") {
super(title);
this.tabs = [];
this.tabBarContainer = document.createElement("div");
this.tabBarContainer.setAttribute("role", "tablist");
this.tabBarContainer.style.display = "flex";
this.tabBarContainer.style.alignItems = "center";
// this.tabBarContainer.style.justifyContent = "space-between";
this.tabBarContainer.style.overflow = "hidden";
this.element.appendChild(this.tabBarContainer);
this.focused = 0;
}
public _onFocus() {
this.tabs[this.focused].focus();
return this;
}
public focus() {
this.tabs[this.focused].focus();
return this;
}
public add(title: string) {
const idx = this.tabs.length;
const elem = new UITab(title);
elem.onClick(() => {
this.selectTab(idx);
});
this.tabs.push(elem);
this.tabBarContainer.appendChild(elem.render());
elem._onConnect();
if (this.tabs.length === 1) this.calculateTabIndex();
return this;
}
public onTabChange(f: (index: number) => void) {
this.onTabChangeCallback = f;
return this;
}
private selectTab(idx: number) {
if (idx !== this.focused) {
this.tabs[this.focused].setTabbable(false);
this.focused = idx;
}
if (!this.onTabChangeCallback) return;
this.onTabChangeCallback(idx);
this.tabs[idx].setTabbable(true);
this.tabs[idx].focus();
this.updateView();
}
public _onKeydown(key: string): boolean {
switch (key) {
case "ArrowLeft":
this.tabs[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.tabs[this.focused].setTabbable(true);
this.selectTab(this.focused);
return true;
break;
case "ArrowRight":
this.tabs[this.focused].setTabbable(false);
this.focused = Math.min(this.tabs.length - 1, this.focused + 1);
this.tabs[this.focused].setTabbable(true);
this.selectTab(this.focused);
return true;
break;
default:
return false;
break;
}
return false;
}
private updateView() {
for (let i = 0; i < this.tabs.length; i++) {
this.tabs[i].setSelected(i === this.focused);
}
}
public getElement(): HTMLElement {
return this.element;
}
public calculateTabIndex() {
this.tabs[this.focused].setTabbable(true);
return this;
}
}

44
frontend/src/ui/tab.ts Normal file
View File

@@ -0,0 +1,44 @@
import { UINode } from "./node";
export class UITab extends UINode {
private textElement: HTMLButtonElement;
private selected: boolean;
public constructor(title: string) {
super(title);
this.title = title;
this.textElement = document.createElement("button");
this.textElement.innerText = title;
this.textElement.setAttribute("tabindex", "-1");
this.textElement.setAttribute("role", "tab");
this.textElement.setAttribute("aria-selected", "false");
this.element.appendChild(this.textElement);
this.selected = false;
}
public focus() {
this.textElement.focus();
return this;
}
public click() {
this.textElement.click();
return this;
}
public getElement(): HTMLElement {
return this.textElement;
}
public setText(text: string) {
this.title = text;
this.textElement.innerText = text;
return this;
}
public setSelected(val: boolean) {
this.selected = val;
this.textElement.setAttribute("aria-selected", this.selected.toString());
return this;
}
}

View File

@@ -0,0 +1,51 @@
import { UINode } from "./node";
import { TabBar } from "./tab-bar";
import { Container } from "./container";
export class TabbedView extends UINode {
private bar: TabBar;
private containers: Container[];
private containerElement: HTMLDivElement;
private barAtTop: boolean;
private currentView?: Container;
public constructor(title: string, barAtTop: boolean = true) {
super(title);
this.bar = new TabBar();
this.bar._onConnect();
this.bar.onTabChange((index: number) => this.onTabChanged(index));
this.containers = [];
this.containerElement = document.createElement("div");
this.element.appendChild(this.bar.render());
this.element.appendChild(this.containerElement);
this.element.setAttribute("tabindex", "-1");
this.barAtTop = barAtTop;
}
public add(name: string, container: Container) {
this.bar.add(name);
container.setRole("tabpanel");
this.containers.push(container);
return this;
}
private onTabChanged(idx: number) {
if (this.currentView) {
this.containerElement.removeChild(this.currentView.render());
}
this.currentView = this.containers[idx];
this.containerElement.appendChild(this.currentView.render());
}
public getElement(): HTMLElement {
return this.containerElement;
}
protected calculateStyle(): void {
if (this.barAtTop) {
this.bar.setPosition(0, 0, 100, 5);
} else {
this.bar.setPosition(0, 90, 100, 5);
}
}
}

View File

@@ -0,0 +1,48 @@
import { UINode } from "./node";
export class TextInput extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `inpt_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `inpt_${this.id}`;
this.inputElement.type = "text";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
return this;
}
public click() {
this.inputElement.click();
return this;
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
return this;
}
}

32
frontend/src/ui/text.ts Normal file
View File

@@ -0,0 +1,32 @@
import { UINode } from "./node";
export class Text extends UINode {
private textElement: HTMLSpanElement;
public constructor(title: string) {
super(title);
this.textElement = document.createElement("span");
this.textElement.innerText = title;
this.textElement.setAttribute("tabindex", "-1");
this.element.appendChild(this.textElement);
}
public focus() {
this.textElement.focus();
return this;
}
public click() {
this.textElement.click();
return this;
}
public getElement(): HTMLElement {
return this.textElement;
}
public setText(text: string) {
this.title = text;
this.textElement.innerText = text;
return this;
}
}

View File

@@ -0,0 +1,43 @@
import { UINode } from "./node";
export class TimePicker extends UINode {
private id: string;
private titleElement: HTMLLabelElement;
private inputElement: HTMLInputElement;
public constructor(title: string) {
super(title);
this.id = Math.random().toString();
this.titleElement = document.createElement("label");
this.titleElement.innerText = title;
this.titleElement.id = `timepicker_title_${this.id}`;
this.inputElement = document.createElement("input");
this.inputElement.id = `timepicker_${this.id}`;
this.inputElement.type = "time";
this.titleElement.appendChild(this.inputElement);
this.element.appendChild(this.titleElement);
}
public focus() {
this.inputElement.focus();
return this;
}
public getElement(): HTMLElement {
return this.inputElement;
}
public setText(text: string) {
this.title = text;
this.titleElement.innerText = text;
return this;
}
public getValue(): string {
return this.inputElement.value;
}
public setValue(value: string) {
this.inputElement.value = value;
return this;
}
}

View File

View File

View File

@@ -0,0 +1,198 @@
import { UINode } from "./node";
import { Treeview } from "./treeview";
export class TreeviewItem extends UINode {
private listElement: HTMLLIElement;
private childContainer!: HTMLUListElement;
public children: TreeviewItem[];
private expanded!: boolean;
private focused: number;
private parent?: TreeviewItem;
private root!: Treeview;
private previousItem?: TreeviewItem;
private nextItem?: TreeviewItem;
public constructor(title: string) {
super(title);
this.listElement = document.createElement("li");
this.listElement.innerText = this.title;
this.listElement.setAttribute("tabindex", "-1");
this.listElement.setAttribute("role", "treeitem");
this.element.appendChild(this.listElement);
this.listElement.setAttribute("aria-label", this.title);
this.children = [];
this.focused = 0;
}
public focus() {
this.listElement.focus();
}
public click() {
this.listElement.click();
}
public getElement(): HTMLElement {
return this.listElement;
}
public setText(text: string) {
this.title = text;
this.listElement.innerText = text;
this.element.setAttribute("aria-label", this.title);
this.listElement.setAttribute("aria-label", this.title);
}
public add(node: TreeviewItem) {
this.children.push(node);
node.setParent(this);
this.setExpanded(false);
if (this.children.length > 0) {
this.previousItem = this.children[this.children.length - 1];
this.previousItem.setNextItem(node);
}
}
public remove(node: TreeviewItem) {
const idx = this.children.indexOf(node);
if (idx > -1) {
this.children.splice(idx, 1);
this.updateShownItems();
}
}
public expand() {
if (!this.isExpandable()) return;
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
this.children[this.focused].expand();
return;
}
this.setExpanded(true);
this.updateShownItems();
this.children[this.focused].focus();
}
public collapse(ignoreFocus: boolean = false) {
if (!this.isExpandable()) {
if (this.getElement() !== document.activeElement && ignoreFocus) return;
this.parent?.collapse(true);
return;
}
this.setExpanded(false);
this.updateShownItems();
setTimeout(() => this.focus(), 0);
}
public isExpandable(): boolean {
return this.children.length > 0;
}
public isExpanded() {
return this.expanded;
}
private setExpanded(val: boolean) {
this.expanded = val;
if (this.expanded) {
this.listElement.setAttribute("aria-expanded", "true");
return;
}
this.listElement.setAttribute("aria-expanded", "false");
}
private updateShownItems() {
if (this.expanded) {
if (!this.childContainer) {
this.childContainer = document.createElement("ul");
this.childContainer.setAttribute("role", "group");
this.children.forEach((child) => this.childContainer.appendChild(child.render()));
this.listElement.appendChild(this.childContainer);
} else {
this.childContainer.hidden = false;
}
} else {
this.childContainer.hidden = true;
// this.listElement.removeChild(this.childContainer);
}
}
public focusNext(): boolean {
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
return this.children[this.focused].focusNext();
}
this.children[this.focused].setTabbable(false);
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
}
public focusPrevious(): boolean {
if (this.isExpandable() && this.isExpanded() && this.children[this.focused].isExpandable()) {
return this.children[this.focused].focusPrevious();
}
this.children[this.focused].setTabbable(false);
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].setTabbable(true);
this.children[this.focused].focus();
return true;
}
public setParent(item: TreeviewItem) {
this.parent = this.parent;
}
public getParent(): TreeviewItem|undefined {
return this.parent;
}
public setPrevious(node: TreeviewItem) {
this.previousItem = node;
}
public setNextItem(node: TreeviewItem) {
this.nextItem = node;
}
public getPrevious(): TreeviewItem|undefined {
return this.previousItem;
}
public getNext(): TreeviewItem|undefined {
return this.nextItem;
}
public _onKeydown(key: string, alt?: boolean, shift?: boolean, ctrl?: boolean): boolean {
switch (key) {
case "ArrowUp":
this.focusPrevious();
return true;
break;
case "ArrowDown":
this.focusNext();
return true;
case "ArrowLeft":
this.collapse();
return true;
case "ArrowRight":
if (this.children[this.focused].isExpandable() && !this.children[this.focused].isExpanded()) {
this.children[this.focused].expand();
return true;
}
break;
default:
return false;
}
return false;
}
public focusOnItem() {
this.children[this.focused].focus();
}
}

151
frontend/src/ui/treeview.ts Normal file
View File

@@ -0,0 +1,151 @@
import { UINode } from "./node";
import { TreeviewItem } from "./treeview-item";
export class Treeview extends UINode {
public children: TreeviewItem[];
protected listElement: HTMLUListElement;
private focused: number;
protected selectCallback?: (id: number) => void;
public constructor(title: string) {
super(title);
this.children = [];
this.listElement = document.createElement("ul");
this.listElement.setAttribute("role", "tree");
this.listElement.style.listStyle = "none";
this.element.appendChild(this.listElement);
this.element.setAttribute("aria-label", this.title);
this.focused = 0;
}
public add(node: TreeviewItem) {
this.children.push(node);
node._onConnect();
this.listElement.appendChild(node.render());
if (this.children.length === 1) this.calculateTabIndex();
node.onFocus(() => this.calculateFocused(node));
}
public remove(node: TreeviewItem) {
const idx = this.children.indexOf(node);
this.children.splice(idx, 1);
node._onDisconnect();
this.listElement.removeChild(node.render());
if (idx === this.focused) {
if (this.focused > 0) this.focused--;
this.calculateTabIndex();
}
}
public _onFocus() {
super._onFocus();
this.children[this.focused].focus();
}
public _onClick() {
this.children[this.focused]._onClick();
}
public _onSelect(id: number) {
if (this.selectCallback) this.selectCallback(id);
}
protected calculateStyle(): void {
super.calculateStyle();
this.element.style.overflowY = "scroll";
this.listElement.style.overflowY = "scroll";
}
public _onKeydown(key: string, alt: boolean = false, shift: boolean = false, ctrl: boolean = false): boolean {
switch (key) {
case "ArrowUp":
return this.focusPrevious();
break;
case "ArrowDown":
return this.focusNext();
break;
case "Enter":
this.children[this.focused].click();
return true;
break;
case "ArrowLeft":
// this.children[this.focused].collapse();
return true;
break;
case "ArrowRight":
this.children[this.focused].expand();
return true;
break;
default:
return this.children[this.focused]._onKeydown(key);
break;
}
return false;
}
protected renderAsListItem(node: UINode) {
let li = document.createElement("li");
li.appendChild(node.render());
return li;
}
public getElement(): HTMLElement {
return this.listElement;
}
public isItemFocused(): boolean {
const has = this.children.find((child) => child.isFocused);
if (has) {
return true;
}
return false;
}
private calculateTabIndex() {
this.children[this.focused].setTabbable(true);
}
public clear() {
this.children.forEach((child) => this.remove(child));
this.children = [];
this.listElement.innerHTML = '';
this.focused = 0;
}
public getFocusedChild() {
return this.children[this.focused];
}
public getFocus() {
return this.focused;
}
public onSelect(f: (id: number) => void) {
this.selectCallback = f;
}
protected calculateFocused(node: TreeviewItem) {
const idx = this.children.indexOf(node);
this._onSelect(idx);
}
public focusPrevious() {
if (this.children[this.focused].isExpanded()) {
// return this.children[this.focused].focusPrevious();
} else {
this.focused = Math.max(0, this.focused - 1);
this.children[this.focused].focus();
}
return true;
}
public focusNext() {
if (this.children[this.focused].isExpanded()) {
// return this.children[this.focused].focusNext();
} else {
this.focused = Math.min(this.children.length - 1, this.focused + 1);
this.children[this.focused].focus();
}
return true;
}
}

61
frontend/src/ui/video.ts Normal file
View File

@@ -0,0 +1,61 @@
import { UINode } from "./node";
export class Video extends UINode {
private videoElement: HTMLVideoElement;
public constructor(title: string, src: string | MediaStream = "") {
super(title);
this.videoElement = document.createElement("video");
if (typeof src === "string") {
this.videoElement.src = src; // Set src if it's a string URL
} else if (src instanceof MediaStream) {
this.videoElement.srcObject = src; // Set srcObject if it's a MediaStream
}
this.videoElement.setAttribute("aria-label", title);
this.element.appendChild(this.videoElement);
this.setRole("video");
}
public getElement(): HTMLElement {
return this.videoElement;
}
public setSource(src: string | MediaStream) {
if (typeof src === "string") {
this.videoElement.src = src;
} else if (src instanceof MediaStream) {
this.videoElement.srcObject = src;
}
return this;
}
public play() {
this.videoElement.play();
return this;
}
public pause() {
this.videoElement.pause();
return this;
}
public setControls(show: boolean) {
this.videoElement.controls = show;
return this;
}
public setLoop(loop: boolean) {
this.videoElement.loop = loop;
return this;
}
public setMuted(muted: boolean) {
this.videoElement.muted = muted;
return this;
}
public setAutoplay(autoplay: boolean) {
this.videoElement.autoplay = autoplay;
return this;
}
}

82
frontend/src/ui/window.ts Normal file
View File

@@ -0,0 +1,82 @@
import { Container } from "./container";
import { UINode } from "./node";
export class UIWindow {
public title: string;
public width!: number;
public height!: number;
public position!: { x: number; y: number; };
public container: Container;
public visible: boolean;
private element: HTMLDivElement;
private rendered!: boolean;
private keyDown: (e: KeyboardEvent) => void;
public constructor(
title: string,
classname?: string,
private setTitle: boolean = true
) {
this.title = title;
this.container = new Container(this.title);
this.container._onConnect();
this.element = document.createElement("div");
if (classname) {
this.element.className = classname;
}
this.keyDown = this.onKeyDown.bind(this);
this.visible = false;
}
public add(node: UINode) {
this.container.add(node);
return this;
}
public remove(node: UINode) {
if (this.container.children.includes(node)) this.container.remove(node);
return this;
}
public show(): HTMLElement|undefined {
if (this.visible) return;
if (this.setTitle) document.title = this.title;
if (this.rendered) return this.element;
this.element.appendChild(this.container.render());
this.element.addEventListener("keydown", this.keyDown);
this.element.focus();
this.visible = true;
this.rendered = true;
return this.element;
}
public hide() {
if (!this.visible) return;
this.visible = false;
this.rendered = false;
this.element.replaceChildren();
this.element.removeEventListener("keydown", this.keyDown);
}
public onKeyDown(e: KeyboardEvent) {
if (this.container._onKeydown(e.key, e.altKey, e.shiftKey, e.ctrlKey)) {
e.preventDefault();
}
}
public onConnect() {
return;
}
public onDisconnect() {
return;
}
public getElement(): HTMLElement {
return this.element;
}
public getContainer(): Container {
return this.container;
}
}