401 lines
14 KiB
JavaScript
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();
|
|
});
|