quickmathjs
Version:
A simple web-based plaintext calculator harnessing the power of math.js for intuitive free-form calculations. Now with a command-line interface (CLI) for testing purposes.
267 lines (224 loc) • 10.1 kB
JavaScript
/*
QuickMathsJS-WebCalc An intuitive web-based calculator using math.js. Features inline results and supports free-form calculations.
Copyright (C) 2023 Brian Khuu
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
let undoStack = [];
let redoStack = [];
let previouslyWarnedMissingResult = false;
/******************************************************************************
* URL Hash Compression and Storage Utilities
******************************************************************************
* The set of functions below enables the storing of the textarea's content
* in a compressed format within the URL's hash. This allows for sharing
* and restoring calculations without the need for external databases or APIs.
*/
/**
* Compresses the content of the textarea and saves it as a Base64 string in the URL's hash.
*/
async function saveToHash() {
const uncompressedText = document.getElementById("input").value;
const compressedBlob = await compressBlob(uncompressedText);
const compressedBase64 = await blobToBase64(compressedBlob);
window.location.hash = compressedBase64;
}
/**
* Loads compressed content from the URL's hash, decompresses it,
* and populates the textarea with the retrieved content.
*/
async function loadFromHash() {
if (window.location.hash) {
const compressedBase64 = window.location.hash.substring(1);
const compressedBlob = await base64ToBlob(compressedBase64);
const decompressedText = await decompressBlob(compressedBlob);
document.getElementById("input").value = decompressedText;
}
}
/**
* Converts a Blob object into a Base64-encoded string.
* @param {Blob} blob - The blob to be converted.
* @returns {Promise<string>} - The Base64-encoded string.
*/
async function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = function() {
resolve(reader.result.split(',')[1]);
};
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* Converts a Base64-encoded string into a Blob object.
* @param {string} base64 - The Base64-encoded string to be converted.
* @returns {Promise<Blob>} - The converted blob.
*/
async function base64ToBlob(base64) {
const response = await fetch("data:application/octet-stream;base64," + base64);
return response.blob();
}
/**
* Compresses the provided text and returns a Blob containing the compressed data.
* @param {string} text - The text to be compressed.
* @returns {Promise<Blob>} - The compressed blob.
*/
async function compressBlob(text) {
const uint8Array = new TextEncoder().encode(text);
const blob = new Blob([uint8Array], { type: "application/octet-stream" });
const compressedStream = blob.stream().pipeThrough(new CompressionStream("gzip"));
return new Response(compressedStream).blob();
}
/**
* Decompresses a Blob object compressed using gzip.
* @param {Blob} blob - The compressed blob.
* @returns {Promise<string>} - The decompressed content as a string.
*/
async function decompressBlob(blob) {
const ds = new DecompressionStream("gzip");
const decompressedStream = blob.stream().pipeThrough(ds);
return new Response(decompressedStream).text();
}
// When the page loads, check for content in the hash and load it
window.addEventListener("load", function() {
// Initialise webcalc internals
webcalc.initialise();
// Load user input
loadFromHash();
});
/******************************************************************************
* Textarea Content Processing and Utilities
******************************************************************************
* The following functions relate to the processing of the textarea's content,
* either in response to user input or when triggered by other UI actions.
*/
let previousContent = ""; // To store the state before "Enter" is pressed
function handleKeyDown(event) {
// Check if undo (Ctrl+Z) or redo (Cmd+Y) is pressed
if (event.ctrlKey && event.key === 'z') {
event.preventDefault();
undo();
return;
}
if (event.ctrlKey && event.key === 'y') {
event.preventDefault();
redo();
return;
}
// Check if the pressed key was "Enter"
if (event.key === 'Enter') {
event.preventDefault(); // Prevent a new line from being added
const textarea = document.getElementById("input");
const cursorPosition = textarea.selectionStart;
// Get the content of the text area up to the cursor position
const contentBeforeCursor = textarea.value.substring(0, cursorPosition);
// Check if cursor is at the start of a line, at the start of the textarea content, or at the end of the content
if (cursorPosition === 0 || contentBeforeCursor.endsWith('\n') || cursorPosition === textarea.value.length) {
// Insert a newline
textarea.value = textarea.value.substring(0, cursorPosition) + '\n' + textarea.value.substring(cursorPosition);
textarea.setSelectionRange(cursorPosition + 1, cursorPosition + 1); // Move cursor after the new line
}
previousContent = textarea.value; // Cache the current state before calculations
// Update the textarea content with the processed content
textarea.value = webcalc.calculate(previousContent); // Trim to avoid any trailing newlines
if (previouslyWarnedMissingResult == false && webcalc.totalSoloExpressions > 0 && webcalc.totalCalculations === 0 && webcalc.totalResultsProvided === 0) {
alert("Tip: Use '=' after an expression to indicate where you want to see the result!");
previouslyWarnedMissingResult = true;
}
// Move the cursor to the beginning of the next line
const nextNewLinePos = textarea.value.indexOf('\n', cursorPosition);
if (nextNewLinePos === -1) { // If there's no next line, move to the end
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
} else {
textarea.setSelectionRange(nextNewLinePos + 1, nextNewLinePos + 1);
}
// After calculating, save content to URL hash
saveToHash();
// Push the new state after Enter is pressed and content is processed
pushToUndo(textarea.value);
return;
}
// Push the new state after Enter is pressed and content is processed
pushToUndo(document.getElementById("input").value);
}
function pushToUndo(content) {
undoStack.push(content);
redoStack = []; // Clear redo stack when new content is added to undo
}
function undo() {
if (undoStack.length > 1) { // Keep at least one state in undoStack to revert to
const currentContent = undoStack.pop();
redoStack.push(currentContent);
document.getElementById("input").value = undoStack[undoStack.length - 1];
}
}
function redo() {
if (redoStack.length > 0) {
const contentToRedo = redoStack.pop();
undoStack.push(contentToRedo);
document.getElementById("input").value = contentToRedo;
}
}
/**
* Inserts the provided text at the current cursor position in the textarea,
* facilitating quicker text entry, especially for characters that take multiple
* button clicks on mobile keyboards.
*
* @param {string} text - The text to be inserted or to replace a selection.
*/
function insertText(text) {
const textarea = document.getElementById("input");
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
// Replace current selection or insert text at cursor position
textarea.value = textarea.value.substring(0, startPos) + text + textarea.value.substring(endPos);
// Place cursor right after the inserted text
textarea.selectionStart = startPos + text.length;
textarea.selectionEnd = startPos + text.length;
// Refocus the textarea
textarea.focus();
}
/**
* Clears the content of the textarea and resets the URL hash,
* providing users a quick way to start fresh. Before clearing,
* the user is prompted for confirmation to prevent accidental data loss.
*/
function clearpad() {
// Exit function if textarea is already empty
if (document.getElementById("input").value == "")
return;
// Ask user for confirmation before clearing
const isConfirmed = window.confirm("Are you sure you want to clear?");
if (!isConfirmed)
return;
// Clear the textarea content and reset the URL hash
document.getElementById("input").value = "";
window.location.hash = "";
// Push the cleared state
pushToUndo("");
}
/**
* Initiates a recalculation of the textarea's content
* and then updates the URL hash with the latest content.
*/
function triggerRecalc() {
const textarea = document.getElementById("input");
previousContent = textarea.value; // Cache the current state before calculations
textarea.value = webcalc.calculate(previousContent);
if (previouslyWarnedMissingResult == false && webcalc.totalSoloExpressions > 0 && webcalc.totalCalculations === 0 && webcalc.totalResultsProvided === 0) {
alert("Tip: Use '=' after an expression to indicate where you want to see the result!");
previouslyWarnedMissingResult = true;
}
saveToHash();
// Push the recalculated state
pushToUndo(textarea.value);
}