snotes-deck/src/main.ts

779 lines
22 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import { save } from "@tauri-apps/plugin-dialog";
import { Note, Settings } from "./model";
import { createWorker } from "tesseract.js";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { homeDir } from "@tauri-apps/api/path";
let notesMsgEl: HTMLElement | null;
let createNoteContentEl: HTMLTextAreaElement | null;
let createNoteTagEl: HTMLInputElement | null;
let searchbarEl: HTMLInputElement | null;
let noteSidebarContainerEl: HTMLDivElement | null;
let searchbarContents = "";
let noteArray: Note[] = [];
/** ID of current note, if we're editing an existing note */
let currentNoteId: number | null = null;
/** reverse the order of note by id in the sidebar */
let reversed = true;
let idModalActive = false;
let typingTimer: number | null = null;
const AUTOSAVE_DELAY = 5000;
enum EditorState {
NEW,
EDITING,
}
enum SearchState {
EMPTY,
RESULTS,
}
/** Editor always initializes in the NEW state */
let editorState = EditorState.NEW;
let searchState = SearchState.EMPTY;
let settings: Settings | null = null;
/**
* Saves the note.
* Or updates an existing note depending on editor state
* TODO: save note when switching to prevent data loss
*/
async function saveNote() {
if (createNoteContentEl && createNoteTagEl) {
switch (editorState) {
case EditorState.NEW:
console.log("creating new note..");
await invoke("create_note", {
content: createNoteContentEl.value,
tag: createNoteTagEl.value,
});
//clearEditor();
refreshSidebarAndOpenLatestNote();
break;
case EditorState.EDITING:
console.log("updating existing note..");
if (currentNoteId !== null) {
await invoke("update_specific_note", {
id: currentNoteId,
content: createNoteContentEl.value,
tag: createNoteTagEl.value,
});
// do not clear the editor
//clearEditor();
} else {
console.error("No note is currently being edited");
}
break;
}
showNotes();
}
}
/**
* Retrieve Notes from DB and fill the sidebar with them.
*
* If there's something in the searchbar, do not clear that.
*/
async function showNotes() {
if (notesMsgEl) {
if (searchState == SearchState.EMPTY) {
const array: Array<any> = await retrieveNotes();
noteArray = array.map((jsonObj) => ({
id: jsonObj.id,
content: jsonObj.content,
date: jsonObj.date,
tag: jsonObj.tag,
}));
fillNoteSidebar(noteArray, reversed);
} else {
searchNote(searchbarContents);
}
}
}
async function retrieveNotes(): Promise<Array<JSON>> {
const notesString: string = await invoke("get_notes_list");
const notesJson = JSON.parse(notesString);
return notesJson;
}
/**
* Handle even listeners on load.
* This does not handle listeners for generated fields like
* the Notes in the sidebar.
* TODO: consistency
*/
window.addEventListener("DOMContentLoaded", async () => {
// settings
settings = await loadSettings();
// db
await invoke("init_db");
console.log("ACTUAL SETTINGS IN FRONTEND: ", settings.fontSize);
createNoteContentEl = document.querySelector("#create-input");
createNoteTagEl = document.querySelector("#create-tag");
searchbarEl = document.querySelector("#note-searchbar");
notesMsgEl = document.querySelector("#notes-list");
// apply font size
if (createNoteContentEl) {
createNoteContentEl.style.fontSize = settings.fontSize + "px";
}
showNotes();
document.querySelector("#save-button")?.addEventListener("click", (e) => {
e.preventDefault();
saveNote();
showNotes();
});
document.querySelector("#new-button")?.addEventListener("click", (e) => {
e.preventDefault();
clearEditor();
showNotes();
});
document
.querySelector("#show-notes-button")
?.addEventListener("click", (e) => {
e.preventDefault();
showNotes();
});
document.querySelector("#export-button")?.addEventListener("click", (e) => {
e.preventDefault();
exportNote(createNoteContentEl ? createNoteContentEl.value : null);
});
document.querySelector("#settings-button")?.addEventListener("click", (e) => {
e.preventDefault();
handleOpenSettingsModal();
});
// Pressing TAB should insert intends in the editor.
// This could potentially cause issues later...
document
.querySelector("#create-input")
?.addEventListener("keydown", (event: Event) => {
const e = event as KeyboardEvent;
if (e.key === "Tab") {
e.preventDefault();
const target = e.target as HTMLTextAreaElement;
const start = target.selectionStart;
const end = target.selectionEnd;
const newValue =
target.value.substring(0, start) + "\t" + target.value.substring(end);
target.value = newValue;
target.setSelectionRange(start + 1, start + 1);
}
});
// searchbar event listener
document
.querySelector("#note-searchbar")
?.addEventListener("input", (event: Event) => {
const target = event.target as HTMLInputElement;
const input = target.value;
if (target.value == "") {
searchState = SearchState.EMPTY;
} else {
searchState = SearchState.RESULTS;
}
searchbarContents = input;
searchNote(input);
});
// sidebar reverse toggle
let reverseIconAscEl = document.querySelector(
"#reverse-icon-asc"
) as HTMLImageElement | null;
let reverseIconDescEl = document.querySelector(
"#reverse-icon-desc"
) as HTMLImageElement | null;
if (reverseIconAscEl && reverseIconDescEl) {
reverseIconDescEl.style.display = "none";
const toggle = () => {
toggleReverse(
reverseIconAscEl as HTMLImageElement,
reverseIconDescEl as HTMLImageElement
);
};
reverseIconAscEl.addEventListener("click", toggle);
reverseIconDescEl.addEventListener("click", toggle);
}
// auto-save timer
createNoteContentEl?.addEventListener("keyup", () => {
if (editorState === EditorState.EDITING) {
if (typingTimer) {
clearTimeout(typingTimer);
}
typingTimer = window.setTimeout(() => saveNote(), AUTOSAVE_DELAY);
}
});
if (createNoteContentEl) {
createNoteContentEl.style.fontSize = settings.fontSize;
}
// OCR
const uploadOcrImageButtonEl = document.getElementById(
"image-button"
) as HTMLButtonElement;
const ocrFileInputEl = document.getElementById(
"fileInput"
) as HTMLInputElement;
uploadOcrImageButtonEl.addEventListener("click", () => {
ocrFileInputEl.click(); // Simulate click on the file input
});
ocrFileInputEl.addEventListener("change", (event) => {
const files = (event.target as HTMLInputElement).files;
if (files) {
console.log("Selected file:", files[0].name);
ocrFileInputEl.src = URL.createObjectURL(files[0]);
(async () => {
// TODO: change ocr language in settings
const worker = await createWorker(
settings ? settings.ocrLanguage : "eng"
);
const ret = await worker.recognize(files[0]);
console.log(ret.data.text);
if (createNoteContentEl) [(createNoteContentEl.value += ret.data.text)];
await worker.terminate();
})();
}
});
// Resizable Sidebar
const sidebar = document.querySelector(".sidebar") as HTMLDivElement;
const resizableHandle = document.querySelector(
".resizable-handle"
) as HTMLDivElement;
let isResizing = false;
resizableHandle.addEventListener("mousedown", () => {
isResizing = true;
document.body.style.cursor = "ew-resize";
});
document.addEventListener("mousemove", (e) => {
if (!isResizing) return;
const newWidth = e.clientX - sidebar.getBoundingClientRect().left;
sidebar.style.width = `${newWidth}px`;
});
document.addEventListener("mouseup", () => {
isResizing = false;
document.body.style.cursor = "default";
});
refreshContextMenuElements();
});
async function loadSettings(): Promise<Settings> {
const defaultSettings: Settings = {
fontSize: "16px",
ocrLanguage: "eng",
};
try {
let loadedSettingsString: string = await invoke("load_settings");
console.log(loadedSettingsString);
if (loadedSettingsString === "") {
await invoke("init_settings");
return defaultSettings;
}
const loadedSettings = JSON.parse(loadedSettingsString);
return loadedSettings as Settings;
} catch (error) {
console.error("An error occurred while loading settings:", error);
return defaultSettings;
}
}
/**
* We need to add new event listeners every time we refresh the note list
*/
function refreshContextMenuElements() {
const elements: NodeListOf<HTMLElement> = document.querySelectorAll(
".rightclick-element"
);
const contextMenu = document.getElementById("contextMenu");
if (contextMenu) {
elements.forEach((element) => {
element.addEventListener("contextmenu", (e: MouseEvent) => {
e.preventDefault();
// get the position with mouse and everything
const mouseX = e.clientX;
const mouseY = e.clientY;
// Calculate the position of the context menu relative to the mouse cursor
const menuWidth = contextMenu.offsetWidth;
const menuHeight = contextMenu.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let posX =
mouseX + menuWidth > viewportWidth ? mouseX - menuWidth : mouseX;
let posY =
mouseY + menuHeight > viewportHeight ? mouseY - menuHeight : mouseY;
contextMenu.style.display = "block";
contextMenu.style.left = `${posX}px`;
contextMenu.style.top = `${posY}px`;
const noteIdElement = element.querySelector(".sidebar-note-id");
if (noteIdElement) {
const noteIdStr = noteIdElement.textContent;
if (noteIdStr) {
const noteId: Number = parseInt(noteIdStr);
showNoteSidebarContextMenu(noteId);
}
} else {
console.error(".sidebar-note-id element not found within the note.");
}
});
});
}
}
function fillNoteSidebar(noteArray: Note[], reverse: boolean) {
noteSidebarContainerEl = document.querySelector("#note-sidebar-container");
if (noteSidebarContainerEl) {
// clear previously existing elements
noteSidebarContainerEl.innerHTML = "";
reverse ? noteArray.reverse() : noteArray;
noteArray.forEach((note) => {
// Create HTML elements for each note
const noteEl: HTMLDivElement = document.createElement("div");
noteEl.classList.add("sidebar-note");
noteEl.classList.add("rightclick-element");
noteEl.addEventListener(
"click",
() => handleSidebarNoteClick(note.id),
false
);
const idSpan: HTMLSpanElement = document.createElement("span");
idSpan.classList.add("sidebar-note-id");
idSpan.textContent = note.id.toString();
const contentSpan: HTMLSpanElement = document.createElement("span");
contentSpan.classList.add("sidebar-note-content");
// Show ... when text is too long
contentSpan.textContent =
note.content.length > 80
? note.content.substring(0, 80) + "..."
: (note.content as string);
contentSpan.title = note.content as string;
const tagSpan: HTMLSpanElement = document.createElement("span");
tagSpan.classList.add("sidebar-note-tag");
tagSpan.textContent =
note.tag.length > 9
? note.tag.substring(0, 11) + ".."
: (note.tag as string);
noteEl.appendChild(idSpan);
noteEl.appendChild(contentSpan);
noteEl.appendChild(tagSpan);
// Append noteEl to the container, if it still exists?
noteSidebarContainerEl
? noteSidebarContainerEl.appendChild(noteEl)
: null;
});
refreshContextMenuElements();
}
}
function handleSidebarNoteClick(id: Number): any {
console.log("clicked note " + id);
if (createNoteContentEl && createNoteTagEl) {
// search for note
let n: Note = {
id: 0,
content: "undefined",
date: "undefined",
tag: "undefined",
};
noteArray.forEach((note) => {
if (note.id === id) {
n = note;
}
});
if (n) {
// save if there's something in the editor currently
// before we open the new note to prevent data loss
if (createNoteContentEl.value != "") {
saveNote();
}
openNote(n);
} else {
// don't destory currently editing note if this fails
console.error("Error fetching note");
}
}
}
function showNoteSidebarContextMenu(noteId: Number) {
const contextMenu = document.getElementById("contextMenu");
const deleteButton = document.getElementById("deleteButton");
if (contextMenu && deleteButton) {
deleteButton.addEventListener("click", async function () {
console.log("Deleting...");
await invoke("delete_specific_note", {
id: noteId,
});
// hide after delete
contextMenu.style.display = "none";
showNotes();
});
// hide when clicking outside of it
document.addEventListener("click", function (event) {
if (!contextMenu.contains(event.target as Node)) {
contextMenu.style.display = "none";
}
});
}
}
/**
* When a note is opened, the editor will switch to the EDITING state and get filled with
* Note content
*/
function openNote(note: Note) {
if (createNoteContentEl && createNoteTagEl) {
createNoteContentEl.value = note.content as string;
createNoteTagEl.value = note.tag as string;
currentNoteId = note.id as number;
// switch state
editorState = EditorState.EDITING;
createNoteContentEl.focus();
}
}
/**
* When new note is clicked, clear the editor content and switch Editor state
*/
function clearEditor() {
if (createNoteContentEl && createNoteTagEl) {
createNoteContentEl.value = "";
createNoteTagEl.value = "";
currentNoteId = null;
editorState = EditorState.NEW;
}
}
// Listen for global key presses
document.addEventListener("keydown", (e) => handleKeyboardShortcuts(e));
/**
* Handle global keyboard shortcuts like save, search, new
*/
function handleKeyboardShortcuts(event: KeyboardEvent) {
// save
if (event.ctrlKey && event.key === "s") {
event.preventDefault();
saveNote();
}
// new
if (event.ctrlKey && event.key === "n") {
event.preventDefault();
clearEditor();
}
// refresh
if (event.ctrlKey && event.key === "r") {
event.preventDefault();
showNotes();
}
// focus searchbox
if (event.ctrlKey && event.key === "f") {
event.preventDefault();
if (searchbarEl) {
searchbarEl.focus();
} else {
console.error("failed to focus on searchbar");
}
}
// open by id: open modal
if (event.ctrlKey && event.key === "t") {
event.preventDefault();
const modalBg = document.getElementById("id-modal-bg");
const modal = document.getElementById("id-modal-container");
const idSearchBar = document.getElementById("id-search");
if (modalBg && modal && idSearchBar) {
modalBg.style.display = "block";
modal.style.display = "block";
idSearchBar.focus();
(idSearchBar as HTMLInputElement).value = "";
idModalActive = true;
modalBg.addEventListener("click", () => {
modal.style.display = "none";
modalBg.style.display = "none";
idModalActive = false;
});
idSearchBar.addEventListener("keydown", async (event: KeyboardEvent) => {
if (event.key === "Enter" && idModalActive) {
let value = (idSearchBar as HTMLInputElement).value;
console.log("value: " + value);
if (await openNoteById(value)) {
modal.style.display = "none";
modalBg.style.display = "none";
idModalActive = false;
} else {
(idSearchBar as HTMLInputElement).value = "";
(idSearchBar as HTMLInputElement).placeholder =
"no Note found for ID";
}
}
if (event.key === "Escape" && idModalActive) {
modal.style.display = "none";
modalBg.style.display = "none";
idModalActive = false;
}
});
} else {
console.error("failed to get modal");
}
}
// quick switch note 1-9
}
/**
* Searches for note and displays the results accordingly
*/
async function searchNote(input: string) {
if (notesMsgEl) {
const array: Array<any> = await getSearchResults(input);
noteArray = array.map((jsonObj) => ({
id: jsonObj.id,
content: jsonObj.content,
date: jsonObj.date,
tag: jsonObj.tag,
}));
fillNoteSidebar(noteArray, reversed);
}
}
async function getSearchResults(input: string): Promise<Array<JSON>> {
const resultsString: string = await invoke("search_notes", {
query: input,
});
const resultsJson = JSON.parse(resultsString);
return resultsJson;
}
/**
* When we save a new note, we want to keep that note
* in the editor, so we switch out the New note for
* Editing an existing note (the latest one, the one
* we just created)
*/
async function refreshSidebarAndOpenLatestNote() {
showNotes();
const noteString = await invoke("get_latest_note");
const noteJson = JSON.parse(noteString as string);
const latestNote: Note = {
id: noteJson[0].id,
content: noteJson[0].content,
date: noteJson[0].date,
tag: noteJson[0].tag,
};
openNote(latestNote);
}
function toggleReverse(
reverseIconAscEl: HTMLImageElement | null,
reverseIconDescEl: HTMLImageElement | null
) {
reversed = !reversed;
if (reverseIconAscEl && reverseIconDescEl) {
if (reverseIconAscEl.style.display !== "none") {
reverseIconAscEl.style.display = "none";
reverseIconDescEl.style.display = "block";
} else {
reverseIconAscEl.style.display = "block";
reverseIconDescEl.style.display = "none";
}
}
showNotes();
}
async function openNoteById(value: string): Promise<boolean> {
const id: Number | null = parseInt(value);
if (id) {
const noteString = await invoke("get_note_by_id", {
id: id,
});
console.log("id called: " + id);
console.log("note string: " + noteString);
const noteJson = JSON.parse(noteString as string);
if (noteJson.length == 0) {
return false;
}
const foundNote: Note = {
id: noteJson[0].id,
content: noteJson[0].content,
date: noteJson[0].date,
tag: noteJson[0].tag,
};
openNote(foundNote);
return true;
}
return false;
}
async function exportNote(contents: string | null) {
if (contents) {
const title = contents.slice(0, 10).trim();
const filePath = await save({
defaultPath: (await homeDir()) + "/" + title + ".md",
filters: [
{
name: "Text",
extensions: ["txt", "md"],
},
],
});
if (filePath) {
await writeTextFile(filePath, contents);
} else {
console.error("Failed to get filePath");
}
} else {
// TODO: have some kind of error banner at the bottom for
// these notifications
console.error("Export note: failed to get note contents");
}
}
function handleOpenSettingsModal() {
const modalBg = document.getElementById("id-modal-bg");
const setingsModalContainer = document.getElementById(
"settings-modal-container"
);
const settingsFontsizeInput = document.getElementById(
"fontsize-setting-input"
) as HTMLInputElement;
const settingsOcrLanguageInput = document.getElementById(
"ocr-language-setting-input"
) as HTMLInputElement;
const settingsSaveButton = document.getElementById("save-settings-button");
if (modalBg && setingsModalContainer && settingsFontsizeInput) {
modalBg.style.display = "block";
setingsModalContainer.style.display = "block";
settingsFontsizeInput.focus();
settingsFontsizeInput.value = settings ? settings.fontSize : "16";
settingsOcrLanguageInput.value = settings ? settings.ocrLanguage : "eng";
modalBg.addEventListener("click", () => {
setingsModalContainer.style.display = "none";
modalBg.style.display = "none";
});
settingsFontsizeInput.addEventListener(
"keydown",
async (event: KeyboardEvent) => {
if (event.key === "Enter") {
console.log("saving settings..");
settings = {
fontSize: settingsFontsizeInput.value,
ocrLanguage:
settingsOcrLanguageInput.value === ""
? "eng"
: settingsOcrLanguageInput.value,
};
await invoke("save_settings", {
settings: JSON.stringify(settings),
});
if (createNoteContentEl) {
createNoteContentEl.style.fontSize =
settingsFontsizeInput.value + "px";
} else {
console.error("failed to get createNoteContentEl");
}
setingsModalContainer.style.display = "none";
modalBg.style.display = "none";
}
if (event.key === "Escape") {
setingsModalContainer.style.display = "none";
modalBg.style.display = "none";
}
}
);
if (settingsSaveButton) {
settingsSaveButton.addEventListener("click", async () => {
console.log("saving settings..");
settings = {
fontSize: settingsFontsizeInput.value,
ocrLanguage:
settingsOcrLanguageInput.value === ""
? "eng"
: settingsOcrLanguageInput.value,
};
await invoke("save_settings", {
settings: JSON.stringify(settings),
});
if (createNoteContentEl) {
createNoteContentEl.style.fontSize =
settingsFontsizeInput.value + "px";
} else {
console.error("failed to get createNoteContentEl");
}
setingsModalContainer.style.display = "none";
modalBg.style.display = "none";
});
} else {
console.error("Failed to get Settings Modal save button.");
}
} else {
console.error("Failed to get Settings Modal elements.");
}
}