delete old frontend
This commit is contained in:
@@ -1,75 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notebrook</title>
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Theme Color (For Mobile idk) -->
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<!-- PWA Metadata -->
|
||||
<meta name="description" content="Notebrook, stream of consciousness accessible note taking" />
|
||||
<meta name="application-name" content="Notebrook" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Notebrook" />
|
||||
<meta name="msapplication-starturl" content="/" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-TileImage" content="/icons/mstile-150x150.png" />
|
||||
|
||||
<style>
|
||||
/* Basic styles for the toasts */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body role="application">
|
||||
<div id="app"></div>
|
||||
<div class="toast-container" aria-live="polite" aria-atomic="true"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function () {
|
||||
navigator.serviceWorker.register('/service-worker.js').then(function (registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
}, function (err) {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"name": "Notebrook",
|
||||
"short_name": "Notebrook",
|
||||
"description": "Stream of conciousness accessible note taking",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/apple-touch-icon.png",
|
||||
"sizes": "180x180",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/mstile-150x150.png",
|
||||
"sizes": "150x150",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
4949
frontend/package-lock.json
generated
4949
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "notebrook-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"idb-keyval": "^6.2.1",
|
||||
"vite-plugin-pwa": "^0.20.1"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,104 +0,0 @@
|
||||
import { IChannel } from "./model/channel";
|
||||
import { IChannelList } from "./model/channel-list";
|
||||
import { IMessage } from "./model/message";
|
||||
import { IUnsentMessage } from "./model/unsent-message";
|
||||
import { state } from "./state";
|
||||
|
||||
|
||||
export const API = {
|
||||
token: "",
|
||||
path: "http://localhost:3000",
|
||||
|
||||
async request(method: string, path: string, body?: any) {
|
||||
if (!API.token) {
|
||||
throw new Error("API token was not set.");
|
||||
}
|
||||
return fetch(`${API.path}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
async checkToken() {
|
||||
const response = await API.request("GET", "check-token");
|
||||
if (response.status !== 200) {
|
||||
throw new Error("Invalid token in request");
|
||||
}
|
||||
},
|
||||
|
||||
async getChannels() {
|
||||
const response = await API.request("GET", "channels");
|
||||
const json = await response.json();
|
||||
return json.channels as IChannel[];
|
||||
},
|
||||
|
||||
async getChannel(id: string) {
|
||||
const response = await API.request("GET", `channels/${id}`);
|
||||
const json = await response.json();
|
||||
return json.channel as IChannel;
|
||||
},
|
||||
|
||||
async createChannel(name: string) {
|
||||
const response = await API.request("POST", "channels", { name });
|
||||
const json = await response.json();
|
||||
return json as IChannel;
|
||||
},
|
||||
|
||||
async deleteChannel(id: string) {
|
||||
await API.request("DELETE", `channels/${id}`);
|
||||
},
|
||||
|
||||
async getMessages(channelId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages`);
|
||||
console.log(response)
|
||||
const json = await response.json();
|
||||
return json.messages as IMessage[];
|
||||
},
|
||||
|
||||
async createMessage(channelId: string, content: string) {
|
||||
const response = await API.request("POST", `channels/${channelId}/messages`, { content });
|
||||
const json = await response.json();
|
||||
return json as IMessage;
|
||||
},
|
||||
|
||||
async deleteMessage(channelId: string, messageId: string) {
|
||||
await API.request("DELETE", `channels/${channelId}/messages/${messageId}`);
|
||||
},
|
||||
|
||||
async uploadFile(channelId: string, messageId: string, file: File | Blob) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${API.path}/channels/${channelId}/messages/${messageId}/files`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": API.token
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const json = await response.json();
|
||||
return json;
|
||||
},
|
||||
|
||||
async mergeChannels(channelId: string, targetChannelId: string) {
|
||||
await API.request("PUT", `channels/${channelId}/merge`, { targetChannelId });
|
||||
},
|
||||
|
||||
async search(query: string, channelId?: string) {
|
||||
const queryPath = channelId ? `search?query=${encodeURIComponent(query)}&channelId=${channelId}` : `search?query=${encodeURIComponent(query)}`;
|
||||
const response = await API.request("GET", queryPath);
|
||||
const json = await response.json();
|
||||
return json.results as IMessage[];
|
||||
},
|
||||
|
||||
async getFiles(channelId: string, messageId: string) {
|
||||
const response = await API.request("GET", `channels/${channelId}/messages/${messageId}/files`);
|
||||
const json = await response.json();
|
||||
return json.files as string[];
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
export class ChunkProcessor<T> {
|
||||
private chunkSize: number;
|
||||
|
||||
constructor(chunkSize: number = 1000) {
|
||||
this.chunkSize = chunkSize;
|
||||
}
|
||||
|
||||
async processArray(array: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
const totalChunks = Math.ceil(array.length / this.chunkSize);
|
||||
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = array.slice(i * this.chunkSize, (i + 1) * this.chunkSize);
|
||||
await this.processChunk(chunk, callback);
|
||||
}
|
||||
}
|
||||
|
||||
private async processChunk(chunk: T[], callback: (chunk: T[]) => void): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
callback(chunk);
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { IChannel } from "../model/channel";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { MergeDialog } from "./merge-dialog";
|
||||
import { RemoveDialog } from "./remove-dialog";
|
||||
|
||||
export class ChannelDialog extends Dialog<IChannel | null> {
|
||||
private channel: IChannel;
|
||||
private nameField: TextInput;
|
||||
private idField: TextInput;
|
||||
private makeDefault: Button;
|
||||
private mergeButton: Button;
|
||||
private deleteButton: Button;
|
||||
|
||||
public constructor(channel: IChannel) {
|
||||
super("Channel info for " + channel.name);
|
||||
this.channel = channel;
|
||||
this.nameField = new TextInput("Channel name");
|
||||
this.nameField.setPosition(25, 10, 50, 10);
|
||||
this.nameField.setValue(channel.name);
|
||||
this.idField = new TextInput("Channel ID (for use with API)");
|
||||
this.idField.setPosition(45, 10, 50, 10);
|
||||
this.idField.setReadonly(true);
|
||||
this.idField.setValue(channel.id.toString());
|
||||
|
||||
this.makeDefault = new Button("Make default");
|
||||
this.makeDefault.setPosition(20, 70, 10, 10);
|
||||
this.makeDefault.onClick(() => {
|
||||
state.defaultChannelId = this.channel.id;
|
||||
showToast(`${channel.name} is now the default channel.`);
|
||||
});
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(40, 70, 10, 10);
|
||||
this.mergeButton.onClick(() => {
|
||||
this.mergeChannel();
|
||||
});
|
||||
if (state.channelList.channels.length === 1) {
|
||||
this.mergeButton.setDisabled(true);
|
||||
}
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(60, 70, 10, 10);
|
||||
this.deleteButton.onClick(() => {
|
||||
this.deleteChannel();
|
||||
});
|
||||
this.add(this.nameField);
|
||||
this.add(this.idField);
|
||||
this.add(this.makeDefault);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.deleteButton);
|
||||
this.setOkAction(() => {
|
||||
this.channel.name = this.nameField.getValue();
|
||||
return this.channel;
|
||||
});
|
||||
}
|
||||
|
||||
private async mergeChannel() {
|
||||
const res = await new MergeDialog().open();
|
||||
if (res) {
|
||||
this.choose(this.channel);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteChannel() {
|
||||
const res = await new RemoveDialog(this.channel.id.toString()).open();
|
||||
if (res) {
|
||||
this.choose(null);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { showToast } from "../speech";
|
||||
import { TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class CreateChannelDialog extends Dialog<string> {
|
||||
private nameField: TextInput;
|
||||
|
||||
public constructor() {
|
||||
super("Create new channel");
|
||||
this.nameField = new TextInput("Name of new channel");
|
||||
this.add(this.nameField);
|
||||
this.setOkAction(() => {
|
||||
return this.nameField.getValue();
|
||||
});
|
||||
this.nameField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.choose(this.nameField.getValue());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { API } from "../api";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class MergeDialog extends Dialog<boolean> {
|
||||
private channelList: Dropdown;
|
||||
private mergeButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Merge channels", false);
|
||||
this.channelList = new Dropdown("Target channel", []);
|
||||
this.channelList.setPosition(10, 10, 80, 20);
|
||||
this.mergeButton = new Button("Merge");
|
||||
this.mergeButton.setPosition(30, 30, 40, 30);
|
||||
this.mergeButton.onClick(() => this.merge());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.channelList);
|
||||
this.add(this.mergeButton);
|
||||
this.add(this.cancelButton);
|
||||
this.setupChannelList();
|
||||
}
|
||||
|
||||
private setupChannelList() {
|
||||
this.channelList.clearOptions();
|
||||
state.channelList.getChannels().forEach((channel) => {
|
||||
if (channel.id !== state.currentChannel!.id) this.channelList.addOption(channel.id.toString(), channel.name);
|
||||
})
|
||||
}
|
||||
private async merge() {
|
||||
const currentChannel = state.currentChannel;
|
||||
const target = this.channelList.getSelectedValue();
|
||||
const targetChannel = state.getChannelById(parseInt(target));
|
||||
console.log(currentChannel, targetChannel);
|
||||
if (!targetChannel || !currentChannel) this.cancel();
|
||||
try {
|
||||
const res = await API.mergeChannels(currentChannel!.id.toString(), target);
|
||||
currentChannel!.messages = [];
|
||||
showToast("Channels were merged.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to merge channels: " + e);
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, Container, TextInput} from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { MultilineInput } from "../ui/multiline-input";
|
||||
import { state } from "../state";
|
||||
export class MessageDialog extends Dialog<IMessage | null> {
|
||||
private message: IMessage;
|
||||
private messageText: MultilineInput;
|
||||
private deleteButton: Button;
|
||||
private fileInfoContainer?: Container;
|
||||
|
||||
public constructor(message: IMessage) {
|
||||
super("Message");
|
||||
this.message = message;
|
||||
this.messageText = new MultilineInput("Message");
|
||||
this.messageText.setValue(message.content);
|
||||
this.messageText.setPosition(10, 10, 80, 20);
|
||||
|
||||
this.deleteButton = new Button("Delete");
|
||||
this.deleteButton.setPosition(10, 90, 80, 10);
|
||||
this.deleteButton.onClick(async () => {
|
||||
await API.deleteMessage(state.currentChannel!.id.toString(), this.message.id.toString());
|
||||
this.choose(null);
|
||||
});
|
||||
this.add(this.messageText);
|
||||
this.add(this.deleteButton);
|
||||
if (this.message.fileId !== null) {
|
||||
this.fileInfoContainer = new Container("File info");
|
||||
this.fileInfoContainer.setPosition(10, 50, 30, 80);
|
||||
this.add(this.fileInfoContainer);
|
||||
this.handleMessage();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage() {
|
||||
if (this.message?.fileType?.toLowerCase().includes("audio")) {
|
||||
const audio = new Audio(`${API.path}/${this.message.filePath}`);
|
||||
audio.autoplay = true;
|
||||
}
|
||||
|
||||
// display info about files, or the image if it is an image. Also display all metadata.
|
||||
this.fileInfoContainer?.add(new Text(`File type: ${this.message.fileType}`));
|
||||
this.fileInfoContainer?.add(new Text(`File path: ${this.message.filePath}`));
|
||||
this.fileInfoContainer?.add(new Text(`File ID: ${this.message.fileId}`));
|
||||
this.fileInfoContainer?.add(new Text(`File size: ${this.message.fileSize}`));
|
||||
this.fileInfoContainer?.add(new Text(`Original name: ${this.message.originalName}`));
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Audio } from "../ui/audio";
|
||||
import { AudioRecorder } from "../ui/audio-recorder";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class RecordAudioDialog extends Dialog<Blob> {
|
||||
private audioRecorder: AudioRecorder;
|
||||
private recordButton: Button;
|
||||
private stopButton: Button;
|
||||
private playButton: Button;
|
||||
private saveButton: Button;
|
||||
private discardButton: Button;
|
||||
private audioBlob: Blob | undefined;
|
||||
private audioPlayer?: Audio;
|
||||
|
||||
constructor() {
|
||||
super("Record audio", false);
|
||||
this.audioRecorder = new AudioRecorder("Record from microphone");
|
||||
this.audioRecorder.onRecordingComplete(() => {
|
||||
this.audioBlob = this.audioRecorder.getRecording();
|
||||
this.saveButton.setDisabled(false);
|
||||
});
|
||||
this.recordButton = new Button("Record");
|
||||
this.recordButton.setPosition(30, 30, 40, 30);
|
||||
this.recordButton.onClick(() => this.startRecording());
|
||||
this.stopButton = new Button("Stop");
|
||||
this.stopButton.setPosition(70, 40, 30, 30);
|
||||
this.stopButton.onClick(() => this.stopRecording());
|
||||
this.stopButton.setDisabled(true);
|
||||
this.saveButton = new Button("Save");
|
||||
this.saveButton.setPosition(10, 80, 50, 20);
|
||||
this.saveButton.onClick(() => this.saveRecording());
|
||||
this.saveButton.setDisabled(true);
|
||||
this.playButton = new Button("Play");
|
||||
this.playButton.setPosition(0, 40, 30, 30);
|
||||
this.playButton.onClick(() => {
|
||||
if (this.audioBlob) {
|
||||
this.audioPlayer = new Audio("Recorded audio");
|
||||
this.audioPlayer.setSource(URL.createObjectURL(this.audioBlob));
|
||||
this.audioPlayer.play();
|
||||
}
|
||||
});
|
||||
this.playButton.setDisabled(true);
|
||||
this.discardButton = new Button("Discard");
|
||||
this.discardButton.setPosition(50, 90, 50, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.recordButton);
|
||||
this.add(this.stopButton);
|
||||
this.add(this.playButton);
|
||||
this.add(this.saveButton);
|
||||
this.add(this.discardButton);
|
||||
}
|
||||
|
||||
private startRecording() {
|
||||
this.audioRecorder.startRecording();
|
||||
this.stopButton.setDisabled(false);
|
||||
this.recordButton.setDisabled(true);
|
||||
}
|
||||
|
||||
private stopRecording() {
|
||||
this.audioRecorder.stopRecording();
|
||||
this.recordButton.setDisabled(false);
|
||||
this.stopButton.setDisabled(true);
|
||||
this.playButton.setDisabled(false);
|
||||
}
|
||||
|
||||
private saveRecording() {
|
||||
if (this.audioBlob) {
|
||||
this.choose(this.audioBlob);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { Text } from "../ui";
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { showToast } from "../speech";
|
||||
|
||||
export class RemoveDialog extends Dialog<boolean> {
|
||||
private content: Text;
|
||||
private confirmButton: Button;
|
||||
protected cancelButton: Button;
|
||||
|
||||
public constructor(channelId: string) {
|
||||
super("Remove channel", false);
|
||||
this.content = new Text("Are you sure you want to remove this channel?");
|
||||
this.confirmButton = new Button("Remove");
|
||||
this.confirmButton.setPosition(30, 30, 40, 30);
|
||||
this.confirmButton.onClick(() => this.doRemove());
|
||||
this.cancelButton = new Button("Cancel");
|
||||
this.cancelButton.setPosition(30, 70, 40, 30);
|
||||
this.cancelButton.onClick(() => this.cancel());
|
||||
this.add(this.content);
|
||||
this.add(this.confirmButton);
|
||||
this.add(this.cancelButton);
|
||||
}
|
||||
|
||||
private async doRemove() {
|
||||
try {
|
||||
const res = await API.deleteChannel(state.currentChannel!.id.toString());
|
||||
state.removeChannel(state.currentChannel!);
|
||||
showToast("Channel was removed.");
|
||||
this.choose(true);
|
||||
} catch (e) {
|
||||
showToast("Failed to remove channel: " + e);
|
||||
|
||||
this.choose(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { IMessage } from "../model/message";
|
||||
import { Button, List, ListItem, TextInput } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class SearchDialog extends Dialog<{channelId: number, messageId: number}> {
|
||||
private searchField: TextInput;
|
||||
private searchButton: Button;
|
||||
private resultsList: List;
|
||||
private closeButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Search for message", false);
|
||||
this.searchField = new TextInput("Search query");
|
||||
this.searchField.setPosition(5, 5, 80, 20);
|
||||
this.searchField.onKeyDown((key) => {
|
||||
if (key === "Enter") {
|
||||
this.searchButton.click();
|
||||
}
|
||||
});
|
||||
this.searchButton = new Button("Search");
|
||||
this.searchButton.setPosition(85, 5, 10, 20);
|
||||
this.searchButton.onClick(async () => {
|
||||
const messages = await API.search(this.searchField.getValue());
|
||||
console.log(messages);
|
||||
this.renderResults(messages);
|
||||
})
|
||||
this.resultsList = new List("Results");
|
||||
this.resultsList.setPosition(5, 20, 90, 70);
|
||||
this.closeButton = new Button("Close");
|
||||
this.closeButton.setPosition(5, 90, 90, 5);
|
||||
this.closeButton.onClick(() => this.cancel());
|
||||
this.add(this.searchField);
|
||||
this.add(this.searchButton);
|
||||
this.add(this.resultsList);
|
||||
this.add(this.closeButton);
|
||||
}
|
||||
|
||||
private renderResults(messages: IMessage[]) {
|
||||
this.resultsList.clear();
|
||||
messages.forEach((message) => {
|
||||
const itm = new ListItem(`${message.content}; ${message.createdAt}`);
|
||||
itm.onClick(() => this.choose({ messageId: message.id, channelId: message.channelId! }));
|
||||
this.resultsList.add(itm);
|
||||
});
|
||||
this.resultsList.focus();
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { Button } from "../ui";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
import { state } from "../state";
|
||||
|
||||
export class SettingsDialog extends Dialog<void> {
|
||||
private resetButton: Button;
|
||||
|
||||
public constructor() {
|
||||
super("Settings");
|
||||
this.resetButton = new Button("Reset frontend");
|
||||
this.resetButton.setPosition(30, 20, 30, 30);
|
||||
this.resetButton.onClick(() => {
|
||||
this.reset();
|
||||
});
|
||||
this.add(this.resetButton);
|
||||
}
|
||||
|
||||
private reset() {
|
||||
state.clear().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { state } from "../state";
|
||||
import { Button } from "../ui";
|
||||
import { Camera } from "../ui/camera";
|
||||
import { Dialog } from "../ui/dialog";
|
||||
|
||||
export class TakePhotoDialog extends Dialog<Blob> {
|
||||
private camera: Camera;
|
||||
private takePhotoButton: Button;
|
||||
private discardButton: Button;
|
||||
|
||||
constructor() {
|
||||
super("Take photo", false);
|
||||
this.camera = new Camera("Photo camera");
|
||||
this.camera.setPosition(10, 15, 80, 75);
|
||||
this.camera.startCamera();
|
||||
this.takePhotoButton = new Button("Take photo");
|
||||
this.takePhotoButton.setPosition(10, 90, 80, 10);
|
||||
this.discardButton = new Button("Cancel");
|
||||
this.discardButton.setPosition(5, 5, 10, 10);
|
||||
this.discardButton.onClick(() => this.cancel());
|
||||
this.add(this.camera);
|
||||
this.add(this.takePhotoButton);
|
||||
this.add(this.discardButton);
|
||||
this.takePhotoButton.onClick(async () => {
|
||||
const photo = await this.camera.savePhotoToBlob();
|
||||
if (photo) this.choose(photo);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
export type MessageCreated = {
|
||||
channelId: string,
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type MessageDeleted = {
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
};
|
||||
|
||||
export type MessageUpdated = {
|
||||
id: string,
|
||||
content: string,
|
||||
};
|
||||
|
||||
export type ChannelCreated = {
|
||||
name: string,
|
||||
};
|
||||
|
||||
export type ChannelDeleted = {
|
||||
channelId: string,
|
||||
};
|
||||
|
||||
export type ChannelUpdated = {
|
||||
channelId: string,
|
||||
name: string,
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
export type Message<T> = {
|
||||
type: string,
|
||||
data?: T,
|
||||
};
|
||||
|
||||
export type MessageHandler<T> = (message: Message<T>) => void;
|
||||
|
||||
export class MessagingSystem {
|
||||
private handlers: Record<string, MessageHandler<any>[]> = {};
|
||||
|
||||
public registerHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (!this.handlers[type]) {
|
||||
this.handlers[type] = [];
|
||||
}
|
||||
if (!this.handlers[type].includes(handler)) {
|
||||
this.handlers[type].push(handler);
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterHandler<T>(type: string, handler: MessageHandler<T>): void {
|
||||
if (this.handlers[type]) {
|
||||
this.handlers[type] = this.handlers[type].filter(h => h !== handler);
|
||||
}
|
||||
}
|
||||
|
||||
public registerHandlerOnce<T>(type: string, handler: MessageHandler<T>): void {
|
||||
const wrappedHandler = (message: Message<T>) => {
|
||||
handler(message);
|
||||
this.unregisterHandler(type, wrappedHandler);
|
||||
};
|
||||
this.registerHandler(type, wrappedHandler);
|
||||
}
|
||||
|
||||
public waitForMessage<T>(type: string, timeout?: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const handler = (message: Message<T>) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
resolve(message.data!);
|
||||
this.unregisterHandler(type, handler);
|
||||
};
|
||||
|
||||
this.registerHandler(type, handler);
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (timeout) {
|
||||
timer = setTimeout(() => {
|
||||
this.unregisterHandler(type, handler);
|
||||
reject(new Error(`Timeout waiting for message of type '${type}'`));
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public sendMessage<T>(message: Message<T>): void {
|
||||
const handlers = this.handlers[message.type];
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => handler(message));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import './style.css'
|
||||
import { MainView } from "./views/main";
|
||||
import { ViewManager } from './views/view-manager';
|
||||
import { AuthorizeView } from './views/authorize';
|
||||
import { state } from './state';
|
||||
import { API } from './api';
|
||||
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await state.load();
|
||||
const vm = new ViewManager();
|
||||
setInterval(() => {
|
||||
state.save();
|
||||
}, 10000);
|
||||
|
||||
if (state.token === "" || state.apiUrl === "") {
|
||||
vm.push(new AuthorizeView(vm));
|
||||
} else {
|
||||
vm.push(new MainView(vm));
|
||||
}
|
||||
document.body.appendChild(vm.render() as HTMLElement);
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Channel, IChannel } from "./channel";
|
||||
|
||||
export interface IChannelList {
|
||||
channels: IChannel[]
|
||||
}
|
||||
|
||||
export class ChannelList implements IChannelList {
|
||||
channels: Channel[] = [];
|
||||
|
||||
constructor(channels?: IChannelList) {
|
||||
this.channels = channels?.channels?.map((chan) => new Channel(chan)) || [];
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel): void {
|
||||
this.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channelId: number): void {
|
||||
this.channels = this.channels.filter(channel => channel.id !== channelId);
|
||||
}
|
||||
|
||||
public getChannel(channelId: number): Channel|undefined {
|
||||
return this.channels.find(channel => channel.id === channelId);
|
||||
}
|
||||
|
||||
public getChannelByName(channelName: string): IChannel|undefined {
|
||||
return this.channels.find(channel => channel.name === channelName);
|
||||
}
|
||||
|
||||
public getChannels(): Channel[] {
|
||||
return this.channels;
|
||||
}
|
||||
|
||||
public getChannelIds(): number[] {
|
||||
return this.channels.map(channel => channel.id);
|
||||
}
|
||||
|
||||
public getChannelNames(): string[] {
|
||||
return this.channels.map(channel => channel.name);
|
||||
}
|
||||
|
||||
public getChannelId(channelName: string): number|undefined {
|
||||
const channel = this.getChannelByName(channelName);
|
||||
return channel ? channel.id : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { IMessage, Message } from "./message";
|
||||
|
||||
export interface IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: IMessage[];
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export class Channel implements IChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
messages: Message[];
|
||||
createdAt: number;
|
||||
private messageToIdMap: Map<number, Message>;
|
||||
|
||||
constructor(channel: IChannel) {
|
||||
this.id = channel.id;
|
||||
this.name = channel.name;
|
||||
this.messages = [];
|
||||
this.messageToIdMap = new Map();
|
||||
channel.messages?.forEach((msg) => this.addMessage(new Message(msg)));
|
||||
this.createdAt = channel.createdAt;
|
||||
}
|
||||
|
||||
public addMessage(message: Message): void {
|
||||
this.messages.push(message);
|
||||
this.messageToIdMap.set(message.id, message);
|
||||
}
|
||||
|
||||
public removeMessage(messageId: number): void {
|
||||
this.messages = this.messages.filter(message => message.id !== messageId);
|
||||
this.messageToIdMap.delete(messageId);
|
||||
}
|
||||
|
||||
public getMessage(messageId: number): Message|undefined {
|
||||
return this.messageToIdMap.get(messageId);
|
||||
}
|
||||
|
||||
public getMessageByContent(content: string): Message|undefined {
|
||||
return this.messages.find(message => message.content === content);
|
||||
}
|
||||
|
||||
public getMessages(): Message[] {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
public getMessageIds(): number[] {
|
||||
return this.messages.map(message => message.id);
|
||||
}
|
||||
|
||||
public getMessageContents(): string[] {
|
||||
return this.messages.map(message => message.content);
|
||||
}
|
||||
|
||||
public getMessageId(content: string): number|undefined {
|
||||
const message = this.getMessageByContent(content);
|
||||
return message ? message.id : undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
export interface IMessage {
|
||||
id: number;
|
||||
channelId?: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export class Message implements IMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
fileId?: number;
|
||||
fileType?: string;
|
||||
filePath?: string;
|
||||
fileSize?: number;
|
||||
originalName?: string;
|
||||
createdAt: string;
|
||||
|
||||
constructor(message: IMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.fileId = message.fileId;
|
||||
this.fileType = message.fileType;
|
||||
this.filePath = message.filePath;
|
||||
this.fileSize = message.fileSize;
|
||||
this.originalName = message.originalName;
|
||||
this.createdAt = message.createdAt;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { IChannelList } from "./channel-list";
|
||||
import { IUnsentMessage } from "./unsent-message";
|
||||
|
||||
export interface IState {
|
||||
token: string;
|
||||
apiUrl: string;
|
||||
defaultChannelId: number;
|
||||
channelList: IChannelList;
|
||||
unsentMessages: IUnsentMessage[];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
}
|
||||
|
||||
export class UnsentMessage implements IUnsentMessage {
|
||||
id: number;
|
||||
content: string;
|
||||
blob?: Blob;
|
||||
createdAt: string;
|
||||
channelId: number;
|
||||
|
||||
constructor(message: IUnsentMessage) {
|
||||
this.id = message.id;
|
||||
this.content = message.content;
|
||||
this.blob = message.blob;
|
||||
this.createdAt = message.createdAt;
|
||||
this.channelId = message.channelId;
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
const CACHE_NAME = 'notebrook-cache-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/favicon.ico',
|
||||
'/intro.wav',
|
||||
'/login.wav',
|
||||
'/uploadfail.wav',
|
||||
'/water1.wav',
|
||||
'/water2.wav',
|
||||
'/water3.wav',
|
||||
'/water4.wav',
|
||||
'/water5.wav',
|
||||
'/water6.wav',
|
||||
'/water7.wav',
|
||||
'/water8.wav',
|
||||
'/water9.wav',
|
||||
'/water10.wav',
|
||||
'/sent1.wav',
|
||||
'/sent2.wav',
|
||||
'/sent3.wav',
|
||||
'/sent4.wav',
|
||||
'/sent5.wav',
|
||||
'/sent6.wav',
|
||||
'/vite.svg',
|
||||
'/src/main.ts'
|
||||
];
|
||||
|
||||
self.addEventListener('install', (event: any) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event: any) => {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Return the cached response if found, otherwise fetch from network
|
||||
return response || fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event: any) => {
|
||||
const cacheWhitelist = [CACHE_NAME];
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheWhitelist.indexOf(cacheName) === -1) {
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
const audioContext = new AudioContext();
|
||||
|
||||
const soundFiles = {
|
||||
intro: 'intro.wav',
|
||||
login: 'login.wav',
|
||||
copy: 'copy.wav',
|
||||
uploadFailed: 'uploadfail.wav'
|
||||
} as const;
|
||||
|
||||
type SoundName = keyof typeof soundFiles;
|
||||
|
||||
const sounds: Partial<Record<SoundName, AudioBuffer>> = {};
|
||||
|
||||
const waterSounds: AudioBuffer[] = [];
|
||||
const sentSounds: AudioBuffer[] = [];
|
||||
|
||||
async function loadSound(url: string): Promise<AudioBuffer> {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return await audioContext.decodeAudioData(arrayBuffer);
|
||||
}
|
||||
|
||||
async function loadAllSounds() {
|
||||
for (const key in soundFiles) {
|
||||
const soundName = key as SoundName;
|
||||
sounds[soundName] = await loadSound(soundFiles[soundName]);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const buffer = await loadSound(`water${i}.wav`);
|
||||
waterSounds.push(buffer);
|
||||
}
|
||||
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const buffer = await loadSound(`sent${i}.wav`);
|
||||
sentSounds.push(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
function playSoundBuffer(buffer: AudioBuffer) {
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start(0);
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName) {
|
||||
const buffer = sounds[name];
|
||||
if (buffer) {
|
||||
playSoundBuffer(buffer);
|
||||
} else {
|
||||
console.error(`Sound ${name} not loaded.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function playWater() {
|
||||
if (waterSounds.length > 0) {
|
||||
const sound = waterSounds[Math.floor(Math.random() * waterSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Water sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
export function playSent() {
|
||||
if (sentSounds.length > 0) {
|
||||
const sound = sentSounds[Math.floor(Math.random() * sentSounds.length)];
|
||||
playSoundBuffer(sound);
|
||||
} else {
|
||||
console.error("Sent sounds not loaded.");
|
||||
}
|
||||
}
|
||||
|
||||
loadAllSounds().then(() => {
|
||||
console.log('All sounds loaded and ready to play');
|
||||
}).catch(error => {
|
||||
console.error('Error loading sounds:', error);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Toast } from "./toast";
|
||||
|
||||
export function speak(text: string, interrupt: boolean = false) {
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
if (interrupt) {
|
||||
speechSynthesis.cancel();
|
||||
}
|
||||
speechSynthesis.speak(utterance);
|
||||
}
|
||||
|
||||
export function showToast(message: string, timeout: number = 5000) {
|
||||
const toast = new Toast(timeout);
|
||||
toast.show(message);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { MessagingSystem } from "./events/messaging-system";
|
||||
import { IChannel, Channel } from "./model/channel";
|
||||
import { IChannelList, ChannelList } from "./model/channel-list";
|
||||
import { IState } from "./model/state";
|
||||
import { IUnsentMessage, UnsentMessage } from "./model/unsent-message";
|
||||
import { get, set, clear } from "idb-keyval";
|
||||
|
||||
|
||||
export class State implements IState {
|
||||
token!: string;
|
||||
apiUrl!: string;
|
||||
channelList!: ChannelList;
|
||||
unsentMessages!: IUnsentMessage[];
|
||||
currentChannel!: Channel | null;
|
||||
defaultChannelId!: number;
|
||||
public events: MessagingSystem;
|
||||
|
||||
constructor() {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.events = new MessagingSystem();
|
||||
}
|
||||
|
||||
public getToken(): string {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
public setToken(token: string): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public getChannelList(): IChannelList {
|
||||
return this.channelList;
|
||||
}
|
||||
|
||||
public setChannelList(channelList: ChannelList): void {
|
||||
this.channelList = channelList;
|
||||
}
|
||||
|
||||
public getUnsentMessages(): IUnsentMessage[] {
|
||||
return this.unsentMessages;
|
||||
}
|
||||
|
||||
public setUnsentMessages(unsentMessages: IUnsentMessage[]): void {
|
||||
this.unsentMessages = unsentMessages;
|
||||
}
|
||||
|
||||
public async save(): Promise<void> {
|
||||
// stringify everything here except the currentChannel object.
|
||||
const { currentChannel, events, ...state } = this;
|
||||
await set("notebrook", state);
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
const saved = await get("notebrook");
|
||||
if (saved) {
|
||||
this.token = saved.token;
|
||||
this.apiUrl = saved.apiUrl;
|
||||
this.channelList = new ChannelList( saved.channelList);
|
||||
this.unsentMessages = saved.unsentMessages.map((message: IUnsentMessage) => new UnsentMessage(message));
|
||||
this.defaultChannelId = saved.defaultChannelId;
|
||||
}
|
||||
}
|
||||
|
||||
public async clear(): Promise<void> {
|
||||
this.token = "";
|
||||
this.channelList = new ChannelList();
|
||||
this.unsentMessages = [];
|
||||
this.currentChannel = null;
|
||||
this.defaultChannelId = -1;
|
||||
|
||||
await clear();
|
||||
}
|
||||
|
||||
public getChannelById(id: number) {
|
||||
return this.channelList.getChannel(id);
|
||||
}
|
||||
|
||||
public getChannelByName(name: string) {
|
||||
return this.channelList.getChannelByName(name);
|
||||
}
|
||||
|
||||
public findChannelByQuery(query: string) {
|
||||
return this.channelList.channels.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
|
||||
}
|
||||
|
||||
public addChannel(channel: Channel) {
|
||||
if (!this.channelList.channels.find((c) => c.id === channel.id)) this.channelList.channels.push(channel);
|
||||
}
|
||||
|
||||
public removeChannel(channel: IChannel) {
|
||||
this.channelList.channels = this.channelList.channels.filter((c) => c.id !== channel.id);
|
||||
}
|
||||
|
||||
public addUnsentMessage(message: UnsentMessage) {
|
||||
this.unsentMessages.push(message);
|
||||
}
|
||||
|
||||
public removeUnsentMessage(message: IUnsentMessage) {
|
||||
this.unsentMessages = this.unsentMessages.filter((m) => m !== message);
|
||||
}
|
||||
|
||||
public getChannels() {
|
||||
return this.channelList.channels;
|
||||
}
|
||||
|
||||
public getCurrentChannel() {
|
||||
return this.currentChannel;
|
||||
}
|
||||
|
||||
public setCurrentChannel(channel: Channel) {
|
||||
this.currentChannel = channel;
|
||||
}
|
||||
|
||||
public getDefaultChannelId() {
|
||||
return this.defaultChannelId;
|
||||
}
|
||||
|
||||
public setDefaultChannelId(id: number) {
|
||||
this.defaultChannelId = id;
|
||||
}
|
||||
|
||||
public getApiUrl() {
|
||||
return this.apiUrl;
|
||||
}
|
||||
|
||||
public setApiUrl(url: string) {
|
||||
this.apiUrl = url;
|
||||
}
|
||||
|
||||
public getMessageById(id: number) {
|
||||
return this.currentChannel!.getMessage(id);
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new State();
|
||||
@@ -1,96 +0,0 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vanilla:hover {
|
||||
filter: drop-shadow(0 0 2em #3178c6aa);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
export class Toast {
|
||||
private container: HTMLElement;
|
||||
private timeout: number;
|
||||
|
||||
constructor(timeout: number = 3000) {
|
||||
this.container = document.querySelector('.toast-container') as HTMLElement;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
public show(message: string): void {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast';
|
||||
toast.textContent = message;
|
||||
|
||||
this.container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast.classList.add('show');
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
this.hide(toast);
|
||||
}, this.timeout);
|
||||
}
|
||||
|
||||
private hide(toast: HTMLElement): void {
|
||||
toast.classList.remove('show');
|
||||
toast.addEventListener('transitionend', () => {
|
||||
toast.remove();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"></path><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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";
|
||||
@@ -1,37 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
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.focusSelectedMessage();
|
||||
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.focusSelectedMessage();
|
||||
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.focusSelectedMessage();
|
||||
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.focusSelectedMessage();
|
||||
return true;
|
||||
break;
|
||||
case "End":
|
||||
this.children[this.focused].setTabbable(false);
|
||||
this.focused = this.children.length - 1;
|
||||
this.children[this.focused].setTabbable(true);
|
||||
this.focusSelectedMessage();
|
||||
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;
|
||||
}
|
||||
|
||||
public focusSelectedMessage() {
|
||||
this.children[this.focused].focus()
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
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.textareaElement.style.whiteSpace = 'pre'; // Prevent text wrapping and preserve
|
||||
this.textareaElement.style.overflow = 'auto'; // Enable scrolling if content overflows
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
public setReadonly(readonly: boolean) {
|
||||
this.inputElement.readOnly = readonly;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { showToast } from "../speech";
|
||||
import { Button, Text, TextInput } from "../ui";
|
||||
import { View } from "./view";
|
||||
import { state } from "../state";
|
||||
import { API } from "../api";
|
||||
import { MainView } from "./main";
|
||||
import { playSound } from "../sound";
|
||||
|
||||
export class AuthorizeView extends View {
|
||||
private welcomeText!: Text;
|
||||
private apiURLInput!: TextInput;
|
||||
private tokenInput!: TextInput;
|
||||
private loginButton!: Button;
|
||||
|
||||
public onActivate(): void {
|
||||
playSound("intro");
|
||||
}
|
||||
public onDeactivate(): void {
|
||||
}
|
||||
public onCreate(): void {
|
||||
this.welcomeText = new Text("Welcome to Notebrook!");
|
||||
this.welcomeText.setPosition(25, 10, 75, 20);
|
||||
this.apiURLInput = new TextInput("API URL");
|
||||
this.apiURLInput.setPosition(40, 40, 20, 20);
|
||||
this.tokenInput = new TextInput("Token");
|
||||
this.tokenInput.setPosition(40, 60, 20, 10);
|
||||
this.loginButton = new Button("Login");
|
||||
this.loginButton.setPosition(40, 70, 20, 10);
|
||||
this.window.add(this.welcomeText);
|
||||
this.window.add(this.apiURLInput);
|
||||
this.window.add(this.tokenInput);
|
||||
this.window.add(this.loginButton);
|
||||
|
||||
this.loginButton.onClick(async () => {
|
||||
const token = this.tokenInput.getValue();
|
||||
const apiUrl = this.apiURLInput.getValue();
|
||||
API.path = apiUrl;
|
||||
API.token = token;
|
||||
try {
|
||||
await API.checkToken();
|
||||
state.token = token;
|
||||
state.apiUrl = apiUrl;
|
||||
state.save();
|
||||
showToast(`Welcome!`, 2000);
|
||||
playSound("login");
|
||||
this.viewManager.push(new MainView(this.viewManager));
|
||||
} catch (e) {
|
||||
showToast(`Invalid API URL or token provided.`);
|
||||
playSound("uploadFailed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onDestroy(): void {
|
||||
}
|
||||
}
|
||||
@@ -1,670 +0,0 @@
|
||||
import { API } from "../api";
|
||||
import { ChunkProcessor } from "../chunk-processor";
|
||||
import { ChannelDialog } from "../dialogs/channel-dialog";
|
||||
import { CreateChannelDialog } from "../dialogs/create-channel";
|
||||
import { MessageDialog } from "../dialogs/message";
|
||||
import { RecordAudioDialog } from "../dialogs/record-audio";
|
||||
import { SearchDialog } from "../dialogs/search";
|
||||
import { SettingsDialog } from "../dialogs/settings";
|
||||
import { TakePhotoDialog } from "../dialogs/take-photo";
|
||||
import { MessageUpdated } from "../events/message-events";
|
||||
import { Channel } from "../model/channel";
|
||||
import { IMessage, Message } from "../model/message";
|
||||
import { UnsentMessage } from "../model/unsent-message";
|
||||
import { playSent, playSound, playWater } from "../sound";
|
||||
import { showToast } from "../speech";
|
||||
import { state } from "../state";
|
||||
import { Button, List, ListItem, TextInput, UINode } from "../ui";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { FileInput } from "../ui/file-input";
|
||||
import { MultilineInput } from "../ui/multiline-input";
|
||||
import { connectToWebsocket } from "../websockets";
|
||||
import { AuthorizeView } from "./authorize";
|
||||
import { View } from "./view";
|
||||
|
||||
export class MainView extends View {
|
||||
private settingsButton!: Button;
|
||||
private channelSwitcher!: Dropdown;
|
||||
private channelInfoButton!: Button;
|
||||
private searchButton!: Button;
|
||||
private fileInput!: FileInput;
|
||||
private messageInput!: MultilineInput;
|
||||
private imageInput!: Button;
|
||||
private voiceMessageInput!: Button;
|
||||
private messageList!: List;
|
||||
private updateInterval!: number;
|
||||
|
||||
private messageElementMap: Map<number, UINode> = new Map();
|
||||
|
||||
private hotkeyMap: Map<string, () => void> = new Map();
|
||||
|
||||
public onActivate(): void {
|
||||
if (!state.currentChannel) {
|
||||
if (state.defaultChannelId) {
|
||||
this.switchChannel(state.defaultChannelId.toString());
|
||||
} else {
|
||||
if (state.channelList.channels.length > 0) this.switchChannel(state.channelList.channels[0].id.toString());
|
||||
}
|
||||
}
|
||||
this.renderInitialMessageList();
|
||||
this.checkAuthorization();
|
||||
this.syncChannels();
|
||||
this.updateChannelList();
|
||||
this.syncMessages();
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.updateVisibleMessageShownTimestamps();
|
||||
}, 10000);
|
||||
setTimeout(() => this.attemptToSendUnsentMessages(), 2000);
|
||||
|
||||
state.events.registerHandler<MessageUpdated>("message-updated", (message) => {
|
||||
const { data } = message;
|
||||
if (!data) return;
|
||||
const channel = state.currentChannel;
|
||||
if (!channel) return;
|
||||
const existing = channel.getMessage(parseInt(data!.id));
|
||||
if (!existing) {
|
||||
return;
|
||||
} else {
|
||||
existing.content = data.content;
|
||||
state.save();
|
||||
const renderedMessage = this.messageElementMap.get(existing.id);
|
||||
if (renderedMessage) {
|
||||
(renderedMessage as ListItem).setText(`${existing.content}; ${this.convertIsoTimeStringToFriendly(existing.createdAt)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", (e) => this.handleHotkey(e));
|
||||
}
|
||||
|
||||
public onDeactivate(): void {
|
||||
clearInterval(this.updateInterval);
|
||||
// unregister hotkey
|
||||
document.removeEventListener("keydown", (e) => this.handleHotkey(e));
|
||||
}
|
||||
|
||||
public onCreate(): void {
|
||||
this.settingsButton = new Button("Settings")
|
||||
.setPosition(0, 0, 10, 10)
|
||||
.onClick(() => this.openSettingsDialog());
|
||||
this.channelSwitcher = new Dropdown("Channel", [])
|
||||
.setPosition(30, 10, 30, 10);
|
||||
this.channelInfoButton = new Button("Channel info")
|
||||
.setPosition(60, 10, 30, 10);
|
||||
this.searchButton = new Button("Search")
|
||||
.setPosition(90, 10, 10, 10)
|
||||
.onClick(async () => this.openSearchDialog());
|
||||
|
||||
this.fileInput = new FileInput("Upload file")
|
||||
.setPosition(0, 90, 15, 10);
|
||||
this.imageInput = new Button("Image")
|
||||
.setPosition(15, 90, 15, 10);
|
||||
this.messageInput = new MultilineInput("New message")
|
||||
.setPosition(30, 90, 60, 10);
|
||||
this.messageInput.getElement().autofocus = true;
|
||||
this.voiceMessageInput = new Button("Voice message")
|
||||
.setPosition(70, 90, 30, 10);
|
||||
|
||||
this.messageList = new List("Messages")
|
||||
.setPosition(30, 30, 60, 50);
|
||||
this.window.add(this.settingsButton)
|
||||
.add(this.channelSwitcher)
|
||||
.add(this.channelInfoButton)
|
||||
.add(this.searchButton)
|
||||
.add(this.messageList)
|
||||
.add(this.messageInput)
|
||||
.add(this.fileInput)
|
||||
.add(this.imageInput)
|
||||
.add(this.voiceMessageInput);
|
||||
this.channelSwitcher.getElement().addEventListener("change", (e) => this.handleChannelSwitcher(e));
|
||||
this.voiceMessageInput.onClick(async () => this.handleVoiceMessageButton());
|
||||
|
||||
this.messageInput.onKeyDown((key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
|
||||
if (key === "Enter") {
|
||||
if (!shift) {
|
||||
console.log(key, alt, shift, ctrl);
|
||||
this.sendMessage();
|
||||
}
|
||||
}
|
||||
});
|
||||
this.channelInfoButton.onClick(() => this.handleChannelInfoButton());
|
||||
this.imageInput.onClick(async () => this.handleImageButton());
|
||||
this.setHotkeys();
|
||||
}
|
||||
|
||||
public onDestroy(): void {
|
||||
|
||||
}
|
||||
|
||||
private async syncChannels() {
|
||||
const channels = await API.getChannels();
|
||||
state.channelList.channels.forEach((chan) => {
|
||||
if (!channels.find((c) => c.id === chan.id)) {
|
||||
state.removeChannel(chan);
|
||||
}
|
||||
});
|
||||
channels.forEach((chan) => state.addChannel(new Channel(chan)));
|
||||
this.updateChannelList();
|
||||
if (!state.currentChannel) {
|
||||
if (state.defaultChannelId) {
|
||||
this.switchChannel(state.defaultChannelId.toString());
|
||||
} else {
|
||||
if (state.channelList.channels.length > 0) {
|
||||
this.switchChannel(state.channelList.channels[0].id.toString());
|
||||
} else {
|
||||
this.createNewChannel();
|
||||
}
|
||||
}
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
|
||||
private updateChannelList() {
|
||||
this.channelSwitcher.clearOptions();
|
||||
state.getChannels().forEach((chan) => {
|
||||
this.channelSwitcher.addOption(chan.id.toString(), chan.name);
|
||||
});
|
||||
this.channelSwitcher.addOption("__new__", "Add new channel");
|
||||
}
|
||||
|
||||
private checkAuthorization() {
|
||||
if (!state.token || !state.apiUrl) {
|
||||
this.viewManager.push(new AuthorizeView(this.viewManager));
|
||||
} else {
|
||||
API.token = state.token;
|
||||
API.path = state.apiUrl;
|
||||
connectToWebsocket();
|
||||
}
|
||||
|
||||
state.save();
|
||||
}
|
||||
|
||||
private async syncMessages() {
|
||||
if (!state.currentChannel) return;
|
||||
if (!state.currentChannel.messages) state.currentChannel.messages = [];
|
||||
const channelId = state.currentChannel.id;
|
||||
if (channelId) {
|
||||
const messages = await API.getMessages(channelId.toString());
|
||||
// first, delete all local messages that are no longer on the remote using the chunk processor
|
||||
const proc = new ChunkProcessor<IMessage>(100);
|
||||
proc.processArray(state.currentChannel.messages, (chunk: IMessage[]) => {
|
||||
chunk.forEach((message: IMessage) => {
|
||||
if (!messages.find((m) => m.id === message.id)) {
|
||||
state.currentChannel!.removeMessage(message.id);
|
||||
const elem = this.messageElementMap.get(message.id);
|
||||
if (elem) {
|
||||
this.messageList.remove(elem);
|
||||
this.messageElementMap.delete(message.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// only render new list items, or list items that have changed.
|
||||
proc.processArray(messages, (chunk: IMessage[]) => {
|
||||
chunk.forEach((message: IMessage) => {
|
||||
// TODO: this could do with a lot of perf improvements. I'll get to it once this is an issue.
|
||||
const existing = state.currentChannel!.getMessage(message.id);
|
||||
if (!existing) {
|
||||
state.currentChannel!.addMessage(new Message(message));
|
||||
this.renderAndAddMessage(message);
|
||||
} else {
|
||||
// TODO: this is awful and needs to be updated, but it works for now.
|
||||
if (existing.content !== message.content || existing.fileId !== message.fileId || existing.filePath !== message.filePath || existing.fileType !== message.fileType || existing.createdAt !== message.createdAt) {
|
||||
existing.content = message.content;
|
||||
existing.fileId = message.fileId;
|
||||
existing.filePath = message.filePath;
|
||||
existing.fileType = message.fileType;
|
||||
existing.createdAt = message.createdAt;
|
||||
existing.fileId = message.fileId;
|
||||
existing.filePath = message.filePath;
|
||||
existing.fileSize = message.fileSize;
|
||||
const renderedMessage = this.messageElementMap.get(message.id);
|
||||
if (renderedMessage) {
|
||||
(renderedMessage as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
|
||||
public switchChannel(channelId: string) {
|
||||
if (this.messageList.children.length > 0) this.messageList.clear();
|
||||
const chan = state.getChannelById(parseInt(channelId));
|
||||
if (!chan) {
|
||||
throw new Error("Could not find channel " + channelId);
|
||||
}
|
||||
state.currentChannel = chan;
|
||||
state.save();
|
||||
}
|
||||
|
||||
private renderMessage(message: IMessage): UINode {
|
||||
const itm = new ListItem(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
|
||||
itm.setUserData(message.id);
|
||||
itm.onClick(() => {
|
||||
this.openMessageDialog(message);
|
||||
})
|
||||
itm.onKeyDown(async (key: string, alt: boolean | undefined, shift: boolean | undefined, ctrl: boolean | undefined) => {
|
||||
if (key === "c") {
|
||||
navigator.clipboard.writeText(message.content.trim());
|
||||
playSound("copy");
|
||||
}
|
||||
|
||||
if (key === "Delete") {
|
||||
await this.removeMessage(message.id);
|
||||
if (this.messageList.children.length === 0) {
|
||||
this.messageInput.focus()
|
||||
} else {
|
||||
this.messageList.focusSelectedMessage()
|
||||
}
|
||||
}
|
||||
});
|
||||
return itm;
|
||||
}
|
||||
|
||||
private renderInitialMessageList(reset: boolean = false) {
|
||||
if (!state.currentChannel) return;
|
||||
if (!state.currentChannel.messages || state.currentChannel.messages.length < 1) return;
|
||||
if (this.messageList.children.length > 0 && !reset) {
|
||||
return;
|
||||
} else {
|
||||
this.messageList.clear();
|
||||
this.messageElementMap.clear();
|
||||
}
|
||||
state.currentChannel.messages.forEach((message) => {
|
||||
this.renderAndAddMessage(message);
|
||||
});
|
||||
this.messageList.scrollToBottom();
|
||||
}
|
||||
|
||||
private async createNewChannel() {
|
||||
const name = await new CreateChannelDialog().open();
|
||||
if (name) {
|
||||
const chan = await API.createChannel(name);
|
||||
state.addChannel(new Channel(chan));
|
||||
this.updateChannelList();
|
||||
if (state.channelList.channels.length < 2) {
|
||||
state.defaultChannelId = chan.id;
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async sendMessage() {
|
||||
if (this.fileInput && this.fileInput.getFiles() && this.fileInput.getFiles()!.length > 0) {
|
||||
return this.uploadFile();
|
||||
}
|
||||
if (this.messageInput.getValue().length > 0) {
|
||||
const messageContent = this.messageInput.getValue();
|
||||
this.messageInput.setValue("");
|
||||
playWater();
|
||||
try {
|
||||
const message: IMessage = await API.createMessage(state.currentChannel!.id.toString(), messageContent);
|
||||
this.messageInput.setValue("");
|
||||
this.renderAndAddMessage(message);
|
||||
this.messageList.scrollToBottom();
|
||||
playSent();
|
||||
state.save();
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
const unsentId = Date.now();
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: messageContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
id: unsentId
|
||||
}));
|
||||
const tmpMessage: IMessage = new Message({
|
||||
id: unsentId,
|
||||
content: messageContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
state.currentChannel!.addMessage(tmpMessage);
|
||||
this.renderAndAddMessage(tmpMessage);
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadVoiceMessage(blob: Blob) {
|
||||
playWater();
|
||||
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "Voice message";
|
||||
this.messageInput.setValue("");
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
|
||||
const id = msg.id;
|
||||
try {
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Something went wrong during message file upload.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
playSound("uploadFailed");
|
||||
showToast("Unable to send message. Will retry later.", 3000);
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: msgContent,
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: blob,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFile() {
|
||||
if (!this.fileInput.getFiles()) return;
|
||||
if (this.fileInput!.getFiles()!.length < 1) return;
|
||||
const file = this.fileInput!.getFiles()![0];
|
||||
if (file) {
|
||||
playWater();
|
||||
const msgContent = this.messageInput.getValue() !== "" ? this.messageInput.getValue() : "File upload";
|
||||
this.messageInput.setValue("");
|
||||
try {
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), msgContent);
|
||||
const id = msg.id;
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), file);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
this.messageInput.setValue("");
|
||||
// reset the file picker
|
||||
(this.fileInput.getElement() as HTMLInputElement).value = "";
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Error while uploading file.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: this.messageInput.getValue(),
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: file,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private convertIsoTimeStringToRelative(isoTimeString: string): string {
|
||||
const date = new Date(isoTimeString);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
if (diff < 1000 * 60) {
|
||||
return `${Math.floor(diff / 1000)} seconds ago`;
|
||||
} else if (diff < 1000 * 60 * 60) {
|
||||
// return both minutes and seconds
|
||||
return `${Math.floor(diff / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
|
||||
} else if (diff < 1000 * 60 * 60 * 24) {
|
||||
// return hours, minutes, seconds ago
|
||||
return `${Math.floor(diff / (1000 * 60 * 60))} hours ${Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))} minutes ${Math.floor((diff % (1000 * 60)) / 1000)} seconds ago`;
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
private convertIsoTimeStringToFriendly(isoTimeString: string): string {
|
||||
const date = new Date(isoTimeString);
|
||||
const now = new Date();
|
||||
if (date.getDate() === now.getDate() && date.getMonth() === now.getMonth() && date.getFullYear() === now.getFullYear()) {
|
||||
return date.toLocaleTimeString();
|
||||
} else {
|
||||
return date.toLocaleString();
|
||||
}
|
||||
}
|
||||
private updateVisibleMessageShownTimestamps() {
|
||||
const lowerIndex = Math.max(this.messageList.getFocus() - 10, 0);
|
||||
const upperIndex = Math.min(this.messageList.getFocus() + 10, this.messageList.children.length);
|
||||
for (let i = lowerIndex; i < upperIndex; i++) {
|
||||
const child = this.messageList.children[i];
|
||||
if (!child) break;
|
||||
const messageId = child.getUserData() as number;
|
||||
const message = state.currentChannel?.getMessage(messageId);
|
||||
if (message) {
|
||||
(child as ListItem).setText(`${message.content}; ${this.convertIsoTimeStringToFriendly(message.createdAt)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attemptToSendUnsentMessages() {
|
||||
state.unsentMessages.forEach(async (msg) => {
|
||||
if (msg.blob) {
|
||||
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
|
||||
const id = apiMsg.id;
|
||||
const response: any = await API.uploadFile(msg.channelId.toString(), id.toString(), msg.blob);
|
||||
if (apiMsg) {
|
||||
apiMsg.fileId = response.fileId;
|
||||
apiMsg.filePath = response.filePath;
|
||||
apiMsg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(apiMsg));
|
||||
this.renderAndAddMessage(apiMsg);
|
||||
playSent();
|
||||
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
|
||||
this.removeSpecificMessageFromList(msg.id);
|
||||
state.save();
|
||||
}
|
||||
} else {
|
||||
const apiMsg = await API.createMessage(msg.channelId.toString(), msg.content);
|
||||
state.currentChannel!.addMessage(new Message(apiMsg));
|
||||
this.renderAndAddMessage(apiMsg);
|
||||
state.unsentMessages = state.unsentMessages.filter((m) => m !== msg);
|
||||
playSent();
|
||||
this.removeSpecificMessageFromList(msg.id);
|
||||
state.save();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearUnsentMessageDisplay() {
|
||||
this.messageList.children.forEach((msg) => {
|
||||
const data = msg.getUserData() as IMessage;
|
||||
if (data.id === -1) {
|
||||
this.messageList.remove(msg);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private removeSpecificMessageFromList(id: number) {
|
||||
const elem = this.messageElementMap.get(id);
|
||||
if (elem) {
|
||||
this.messageList.remove(elem);
|
||||
this.messageElementMap.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadImage(blob: Blob) {
|
||||
playWater();
|
||||
try {
|
||||
const msg = await API.createMessage(state.currentChannel!.id.toString(), "Image");
|
||||
const id = msg.id;
|
||||
const response: any = await API.uploadFile(state.currentChannel!.id.toString(), id.toString(), blob);
|
||||
if (msg) {
|
||||
msg.fileId = response.fileId;
|
||||
msg.filePath = response.filePath;
|
||||
msg.fileType = response.fileType;
|
||||
state.currentChannel!.addMessage(new Message(msg));
|
||||
this.renderAndAddMessage(msg);
|
||||
playSent();
|
||||
state.save();
|
||||
} else {
|
||||
showToast("Error while uploading file.");
|
||||
playSound("uploadFailed");
|
||||
// TODO: Handle the case when no message is found
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("Could not post message. Will retry later.", 3000);
|
||||
playSound("uploadFailed");
|
||||
state.unsentMessages.push(new UnsentMessage({
|
||||
channelId: state.currentChannel!.id,
|
||||
content: "Image",
|
||||
createdAt: new Date().toISOString(),
|
||||
blob: blob,
|
||||
id: Date.now()
|
||||
}));
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private renderAndAddMessage(message: IMessage) {
|
||||
const elem = this.renderMessage(message);
|
||||
this.messageList.add(elem);
|
||||
this.messageElementMap.set(message.id, elem);
|
||||
}
|
||||
|
||||
private async openSettingsDialog() {
|
||||
const d = new SettingsDialog();
|
||||
d.open();
|
||||
}
|
||||
|
||||
private async openSearchDialog() {
|
||||
const searchDialog = new SearchDialog();
|
||||
const res = await searchDialog.open();
|
||||
if (res) {
|
||||
if (res.channelId && res.messageId) {
|
||||
if (state.currentChannel?.id !== res.channelId) {
|
||||
this.switchChannel(res.channelId.toString());
|
||||
this.renderInitialMessageList();
|
||||
}
|
||||
const message = state.currentChannel!.getMessage(res.messageId);
|
||||
if (message) {
|
||||
this.messageElementMap.get(message.id)?.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleChannelSwitcher(e: Event) {
|
||||
const target = e.target as HTMLSelectElement;
|
||||
if (target.value === "__new__") {
|
||||
this.createNewChannel();
|
||||
} else {
|
||||
this.switchChannel(target.value);
|
||||
this.renderInitialMessageList();
|
||||
this.syncMessages();
|
||||
}
|
||||
}
|
||||
|
||||
private async handleVoiceMessageButton() {
|
||||
const blob = await new RecordAudioDialog().open();
|
||||
if (blob) {
|
||||
this.uploadVoiceMessage(blob);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleChannelInfoButton() {
|
||||
if (this.channelSwitcher.getSelectedValue() === "__new__") {
|
||||
this.createNewChannel();
|
||||
return;
|
||||
}
|
||||
const d = new ChannelDialog(state.currentChannel!);
|
||||
d.open().then((chan) => {
|
||||
if (!chan) {
|
||||
state.removeChannel(state.currentChannel!);
|
||||
state.currentChannel = null;
|
||||
this.updateChannelList();
|
||||
state.save();
|
||||
if (state.channelList.channels.length > 0) {
|
||||
return this.switchChannel(state.channelList.channels[0].id.toString());
|
||||
} else {
|
||||
return this.createNewChannel();
|
||||
}
|
||||
}
|
||||
if (chan.messages.length < 1) {
|
||||
this.renderInitialMessageList(true);
|
||||
this.syncMessages();
|
||||
}
|
||||
state.save();
|
||||
this.updateChannelList();
|
||||
});
|
||||
}
|
||||
|
||||
private async handleImageButton() {
|
||||
const photo = await new TakePhotoDialog().open();
|
||||
this.uploadImage(photo);
|
||||
}
|
||||
|
||||
private setHotkeys() {
|
||||
this.hotkeyMap.set("s", () => this.openSettingsDialog());
|
||||
this.hotkeyMap.set("c", () => this.channelSwitcher.focus());
|
||||
this.hotkeyMap.set("x", () => this.handleChannelInfoButton());
|
||||
this.hotkeyMap.set("f", () => this.openSearchDialog());
|
||||
this.hotkeyMap.set("v", () => this.handleVoiceMessageButton());
|
||||
this.hotkeyMap.set(" ", () => this.messageInput.focus());
|
||||
}
|
||||
|
||||
private handleHotkey(e: KeyboardEvent) {
|
||||
let index = 10;
|
||||
if ((e.key.match(/[1-9]/) || e.key === '0') && e.altKey) {
|
||||
e.preventDefault();
|
||||
if (e.key === '0') index = 10;
|
||||
index = parseInt(e.key);
|
||||
const messages = state.currentChannel?.messages;
|
||||
if (messages && messages.length > 0) {
|
||||
const msg = messages[messages.length - index];
|
||||
if (msg) {
|
||||
showToast(`${msg.content}; ${this.convertIsoTimeStringToFriendly(msg.createdAt)}`, 200);
|
||||
} else {
|
||||
showToast('No message is available in this position', 200);
|
||||
}
|
||||
} else {
|
||||
showToast('There are no messages in this channel right now', 200)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const action = this.hotkeyMap.get(e.key.toLowerCase());
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async openMessageDialog(message: IMessage) {
|
||||
const d = new MessageDialog(message);
|
||||
const msg = await d.open();
|
||||
if (!msg || msg === null) {
|
||||
state.currentChannel?.removeMessage(message.id);
|
||||
const node = this.messageElementMap.get(message.id);
|
||||
if (node) {
|
||||
this.messageList.remove(node);
|
||||
this.messageElementMap.delete(message.id);
|
||||
}
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async removeMessage(id: number) {
|
||||
if (state.currentChannel) {
|
||||
await API.deleteMessage(state.currentChannel.id.toString(), id.toString());
|
||||
state.currentChannel.removeMessage(id);
|
||||
const node = this.messageElementMap.get(id);
|
||||
if (node) {
|
||||
this.messageList.remove(node);
|
||||
this.messageElementMap.delete(id);
|
||||
state.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { UIWindow } from "../ui/window";
|
||||
import { View } from "./view";
|
||||
|
||||
export class ViewManager {
|
||||
private currentView: View | undefined | null;
|
||||
private views: View[];
|
||||
private window: UIWindow;
|
||||
public constructor() {
|
||||
this.views = [];
|
||||
this.window = new UIWindow("Notebrook");
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
public add(view: View) {
|
||||
this.views.push(view);
|
||||
view.onCreate();
|
||||
}
|
||||
|
||||
public remove(view: View) {
|
||||
this.views.splice(this.views.indexOf(view), 1);
|
||||
view.onDestroy();
|
||||
if (view === this.currentView) this.window.remove(this.currentView.show());
|
||||
if (this.currentView) this.currentView.setActive(false);
|
||||
this.currentView = null;
|
||||
}
|
||||
|
||||
public switchTo(view: View) {
|
||||
if (!this.views.includes(view)) {
|
||||
throw new Error("View not initialized");
|
||||
}
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
}
|
||||
this.currentView = view;
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
|
||||
public render(): HTMLElement|undefined {
|
||||
return this.window.show();
|
||||
}
|
||||
|
||||
public push(view: View) {
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
}
|
||||
|
||||
this.views.unshift(view);
|
||||
this.currentView = view;
|
||||
this.currentView.onCreate();
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
|
||||
public pop() {
|
||||
if (this.currentView) {
|
||||
this.currentView.onDeactivate();
|
||||
this.currentView.setActive(false);
|
||||
this.window.remove(this.currentView.show());
|
||||
this.currentView.onDestroy();
|
||||
}
|
||||
this.views.splice(0, 1);
|
||||
this.currentView = this.views[0];
|
||||
this.currentView.setActive(true);
|
||||
this.currentView.onActivate();
|
||||
this.window.add(this.currentView.show());
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Container } from "../ui/container";
|
||||
import { UIWindow } from "../ui/window";
|
||||
import { ViewManager } from "./view-manager";
|
||||
|
||||
export abstract class View {
|
||||
protected viewManager: ViewManager;
|
||||
protected window: Container;
|
||||
private active!: boolean;
|
||||
public constructor(viewManager: ViewManager) {
|
||||
this.viewManager = viewManager;
|
||||
this.window = new Container("Base view");
|
||||
}
|
||||
|
||||
public show() {
|
||||
return this.window;
|
||||
}
|
||||
|
||||
public abstract onActivate(): void;
|
||||
|
||||
public abstract onDeactivate(): void;
|
||||
|
||||
public abstract onCreate(): void;
|
||||
|
||||
public abstract onDestroy(): void;
|
||||
|
||||
public isActive() {
|
||||
return this.isActive;
|
||||
}
|
||||
|
||||
public setActive(val: boolean) {
|
||||
this.active = val;
|
||||
}
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,18 +0,0 @@
|
||||
import { API } from "./api";
|
||||
import { state } from "./state";
|
||||
|
||||
export const connectToWebsocket = () => {
|
||||
const ws = new WebSocket(`ws://localhost:3000`);
|
||||
ws.onopen = () => {
|
||||
console.log("Connected to websocket server");
|
||||
}
|
||||
ws.onmessage = (data) => {
|
||||
const message = JSON.parse(data.data.toString());
|
||||
state.events.sendMessage(message);
|
||||
console.log(message);
|
||||
}
|
||||
ws.onclose= () => {
|
||||
console.log("Disconnected from websocket server");
|
||||
}
|
||||
return ws;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['intro.wav', 'login.wav', 'sent1.wav', 'sent2.wav', 'sent3.wav', 'sent4.wav', 'sent5.wav', 'sent6.wav', 'uploadfail.wav', 'water1.wav', 'water2.wav', 'water3.wav', 'water4.wav', 'water5.wav', 'water6.wav', 'water7.wav', 'water8.wav', 'water9.wav', 'water10.wav', 'index.html'],
|
||||
manifest: {
|
||||
name: 'Notebrook',
|
||||
short_name: 'Notebrook',
|
||||
description: 'Notebrook, stream of consciousness accessible note taking',
|
||||
theme_color: '#ffffff',
|
||||
icons: [
|
||||
{
|
||||
src: 'icons/192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: 'icons/512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
|
||||
// workbox options for the service worker
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
Reference in New Issue
Block a user