converter/src/main/js/main.js

401 lines
14 KiB
JavaScript

// noinspection JSUnresolvedVariable
const {$, doAfterLoad, stringToHtml} = window.fwdekker;
// noinspection JSUnresolvedVariable
const {clearInputValidity, showInputInvalid} = window.fwdekker.validation;
import bigInt from "big-integer";
/**
* Replaces the character at the given index with the given replacement.
*
* @param str the string to replace in
* @param index the index in the given string to replace at
* @param replacement the replacement to insert into the string
* @returns {string} the input string with one character replaced
*/
function stringReplaceAt(str, index, replacement) {
return str.substring(0, index) + replacement + str.substring(index + replacement.length);
}
/**
* Replaces all instances of the target with the replacement.
*
* @param str the string to replace in
* @param target the character to replace
* @param replacement the replacement to insert into the string
* @returns {string} the input string with all instances of the target replaced
*/
function stringReplaceAll(str, target, replacement) {
return str.split(target).join(replacement);
}
/**
* Runs `stringReplaceAll` for each character in `targets` and `replacements`.
*
* @param str the string to replace in
* @param targets the characters to replace
* @param replacements the replacements to insert into the string; each character here corresponds to a character
* in the targets string
* @returns {string} the input string with all instances of the targets replaced
*/
function stringReplaceAlls(str, targets, replacements) {
return Array.from(targets)
.reduce((output, target, index) => stringReplaceAll(output, target, replacements[index]), str);
}
/**
* A numeral system, expressing a conversion from decimal to this system.
*/
class NumeralSystem {
/**
* Constructs a new numeral system.
*
* @param base the base
* @param alphabet the symbols representing values in this system
* @param caseSensitive `true` if and only if capitalization affects the value of a number in this system
*/
constructor(base, alphabet, caseSensitive = false) {
this.base = base;
this.alphabet = alphabet;
this.caseSensitive = caseSensitive;
}
/**
* Converts a decimal number to a number in this system.
*
* @param decimalNumber {bigInt} the decimal number to convert
* @returns {string} the representation of `decimalNumber` in this system
*/
decimalToBase(decimalNumber) {
return decimalNumber.toString(this.base, this.alphabet);
}
/**
* Converts multiple decimal numbers to numbers in this system.
*
* @param decimalNumbers {bigInt[]} the decimal numbers to convert
* @returns {string[]} the representations of `decimalNumbers` in this system
*/
decimalsToBases(decimalNumbers) {
return decimalNumbers.map(it => this.decimalToBase(it));
}
/**
* Converts a number from this system to decimal.
*
* @param baseString {string} the number in this system to convert
* @returns {bigInt} the decimal representation of `baseString`
*/
baseToDecimal(baseString) {
return bigInt(baseString, this.base, this.alphabet, this.caseSensitive);
}
/**
* Converts numbers from this system to decimal.
*
* @param baseStrings {string[]} the numbers in this system to convert
* @returns {bigInt[]} the decimal representations of `baseStrings`
*/
basesToDecimals(baseStrings) {
return baseStrings.map(it => this.baseToDecimal(it));
}
/**
* Checks whether `testString` is a legal substring of a number in this numerical system.
*
* @param string {string} a substring of a number in this numerical system
* @returns {boolean} the filtered base string
*/
isLegal(string) {
const alphabet = this.caseSensitive
? (this.alphabet + ",")
: (this.alphabet.toLowerCase() + this.alphabet.toUpperCase() + ",");
// Regex from https://stackoverflow.com/a/3561711/
const alphabetRegex = alphabet.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
return (new RegExp(`^[${alphabetRegex}]*$`)).test(string);
}
}
/**
* An input field that contains a value in some numeral system.
*/
class NumeralSystemInput {
/**
* Constructs a new numeral system input, including HTML elements.
*
* @param name {string} the human-readable name of the system
* @param numeralSystem {NumeralSystem} the numeral system in which values in this input are expressed
*/
constructor(name, numeralSystem) {
this.name = name;
this.numeralSystem = numeralSystem;
this.wrapper = document.createElement("article");
const base = this.numeralSystem.base;
const label = stringToHtml(`<label for="b${base}-input">${this.name} <small>(base ${base})</small></label>`);
this.wrapper.appendChild(label);
this.textarea = stringToHtml(`<textarea id="b${base}-input" class="number-input"></textarea>`);
this.textarea.addEventListener("beforeinput", event => {
if (!this.numeralSystem.isLegal(event.data))
event.preventDefault();
});
this.textarea.addEventListener("input", () => {
if (this.textarea.value === "") return;
updateAllInputs(this, this.numeralSystem.basesToDecimals(this.getValues()));
});
this.wrapper.appendChild(this.textarea);
const hint = stringToHtml(`<small id="b${base}-input-hint" data-hint-for="b${base}-input"></small>`);
this.wrapper.appendChild(hint);
}
/**
* Appends this input's elements to `parent.`
*
* @param parent {HTMLElement} the element to add this input to
*/
addToParent(parent) {
parent.appendChild(this.wrapper);
}
/**
* Returns the input's values.
*
* @returns {string[]} the input's values expressed in the associated numeral system
*/
getValues() {
return this.textarea.value.split(",");
}
/**
* Updates the input's value to contain this input's numeral system's representation of `decimalNumbers`.
*
* @param decimalNumbers {bigInt[]} the decimal numbers to represent in the input
*/
setValues(decimalNumbers) {
this.textarea.value = this.numeralSystem.decimalsToBases(decimalNumbers).join(",");
}
}
/**
* The base-64 `NumeralSystem`.
*/
class Base64NumeralSystem extends NumeralSystem {
// TODO Convert static methods to static properties once supported by Firefox
/**
* @returns {string} the default base-64 alphabet, including padding
*/
static defaultAlphabet() {
return "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
}
/**
* Constructs a new base-64 numeral system.
*
* @param alphabet {string} the 64 characters to encode numbers with, and the padding character at the end
*/
constructor(alphabet) {
super(64, alphabet, true);
}
/**
* Converts a decimal number to base-64.
*
* @param decimalNumber {bigInt} the decimal number to convert to base-64
* @returns {string} the base-64 representation of `decimalNumber`
*/
decimalToBase(decimalNumber) {
const hex = decimalNumber.toString(16);
const b64 = Array.from(hex.padStart(hex.length + hex.length % 2))
.reduce((result, value, index, array) => {
if (index % 2 === 0) result.push(array.slice(index, index + 2));
return result;
}, [])
.map(pair => String.fromCharCode(parseInt(pair.join(""), 16)))
.join("");
return stringReplaceAlls(btoa(b64), Base64NumeralSystem.defaultAlphabet(), this.alphabet);
}
/**
* Converts a base-64 number to decimal.
*
* @param baseString {string} the base-64 number to convert to decimal
* @returns {bigInt} the decimal representation of `baseString`
*/
baseToDecimal(baseString) {
if (baseString.length % 4 === 1) throw new Error("Invalid input string length.");
const normalBaseString = stringReplaceAlls(baseString, this.alphabet, Base64NumeralSystem.defaultAlphabet());
const hex = Array.from(atob(normalBaseString))
.map(char => char.charCodeAt(0).toString(16).padStart(2, "0")).join("");
return bigInt(hex, 16);
}
}
/**
* An input field for base-64 numbers.
*/
class Base64NumeralSystemInput extends NumeralSystemInput {
/**
* @returns {Object.<string, [string, string]>} the variants for the last two characters in the base-64 alphabet
*/
// TODO Convert static methods to static properties once supported by Firefox
static dropdownOptions() {
return {"Standard": ['+', '/'], "Filename": ['-', '_'], "IMAP": ['+', ',']};
}
/**
* Constructs a new base-64 input, including HTML elements.
*
* @param name {string} the human-readable name of the system
*/
constructor(name) {
super(name, new Base64NumeralSystem(Base64NumeralSystem.defaultAlphabet()));
const dropdown = stringToHtml(`<select id="${this.numeralSystem.base}-dropdown"></select>`);
dropdown.addEventListener("change", () => {
const selectedOption = Base64NumeralSystemInput.dropdownOptions()[dropdown.value];
this.setLastDigits(selectedOption[0], selectedOption[1]);
});
this.wrapper.appendChild(dropdown);
Object
.keys(Base64NumeralSystemInput.dropdownOptions())
.map(key => {
const text = key + ": " + Base64NumeralSystemInput.dropdownOptions()[key].join("");
return stringToHtml(`<option value="${key}">${text}</option>`);
})
.forEach(option => dropdown.appendChild(option));
}
/**
* Changes the last two symbols of the alphabet.
*
* @param c62 the new 62nd (0-indexed) symbol of the alphabet
* @param c63 the new 63rd (0-indexed) symbol of the alphabet
*/
setLastDigits(c62, c63) {
const oc62 = this.numeralSystem.alphabet[62];
const oc63 = this.numeralSystem.alphabet[63];
this.numeralSystem.alphabet = stringReplaceAt(stringReplaceAt(this.numeralSystem.alphabet, 62, c62), 63, c63);
this.textarea.value = stringReplaceAll(stringReplaceAll(this.textarea.value, oc62, c62), oc63, c63);
}
}
/**
* An input field that contains a value in some numeral system, and can optionally interpret the separator symbol
* literally as part of its alphabet.
*
* Useful for numeral systems with separators in their alphabet. Lets the user decide whether to interpret those values
* as literals or as separators.
*/
class NumeralSystemInputWithToggleableSeparator extends NumeralSystemInput {
/**
* Constructs a new numeral system input, including HTML elements.
*
* @param name {string} the human-readable name of the system
* @param numeralSystem {NumeralSystem} the numeral system in which values in this input are expressed
*/
constructor(name, numeralSystem) {
if (!numeralSystem.alphabet.includes(","))
console.warn("Toggleable separator input incorrectly used on numeral system without comma in alphabet.");
super(name, numeralSystem);
this.textarea.addEventListener("input", () => clearInputValidity(this.textarea));
const id = `b${this.numeralSystem.base}-separator-toggle`;
const checkboxContainer = document.createElement("div");
this.wrapper.appendChild(checkboxContainer);
this.separatorCheckbox = stringToHtml(`<input type="checkbox" role="switch" id="${id}" checked />`);
this.separatorCheckbox.addEventListener("change", () => this.textarea.dispatchEvent(new InputEvent("input")));
checkboxContainer.appendChild(this.separatorCheckbox);
const label = stringToHtml(`<label for="${id}">Comma separates values</label>`);
checkboxContainer.appendChild(label);
}
getValues() {
if (this.separatorCheckbox.checked)
return super.getValues();
else
return [this.textarea.value];
}
setValues(decimalNumbers) {
clearInputValidity(this.textarea);
const baseStrings = this.numeralSystem.decimalsToBases(decimalNumbers);
if (baseStrings.length === 1) {
this.separatorCheckbox.checked = !baseStrings[0].includes(",");
this.textarea.value = baseStrings[0];
} else {
if (baseStrings.some(it => it.includes(",")))
showInputInvalid(this.textarea, "Contains a mixture of literal commas and separator commas.", false);
this.textarea.value = baseStrings.join(",");
}
}
}
/**
* All the inputs to display.
*
* @type {NumeralSystemInput[]} all the inputs to display
*/
const inputs = [
new NumeralSystemInput("Binary", new NumeralSystem(2, "01")),
new NumeralSystemInput("Octal", new NumeralSystem(8, "01234567")),
new NumeralSystemInput("Decimal", new NumeralSystem(10, "0123456789")),
new NumeralSystemInput("Duodecimal", new NumeralSystem(12, "0123456789ab")),
new NumeralSystemInput("Hexadecimal", new NumeralSystem(16, "0123456789abcdef")),
new Base64NumeralSystemInput("Base64"),
new NumeralSystemInputWithToggleableSeparator(
"ASCII",
new NumeralSystem(
256,
new Array(256).fill(0).map((_, it) => String.fromCharCode(it)).join(""),
true
)
),
];
/**
* Updates the values of all inputs to represent `newValue`.
*
* @param source {NumeralSystemInput} the input that triggered the update
* @param newDecimalValues {bigInt[]} the decimal representation of the new value
*/
function updateAllInputs(source, newDecimalValues) {
for (const input of inputs)
if (input !== source)
input.setValues(newDecimalValues);
}
doAfterLoad(() => {
const form = $("#inputs");
for (const input of inputs)
input.addToParent(form);
updateAllInputs(undefined, [bigInt(42), bigInt(17)]);
inputs[0].textarea.focus();
});