518 lines
14 KiB
JavaScript
518 lines
14 KiB
JavaScript
const calc = new RpnCalculator({ angleMode: 'deg' });
|
|
|
|
const hiddenInput = document.getElementById('hiddenInput');
|
|
const modeButton = document.getElementById('modeButton');
|
|
const pasteButton = document.getElementById('pasteButton');
|
|
const upButton = document.getElementById('upButton');
|
|
const constButton = document.getElementById('constButton');
|
|
const leftButton = document.getElementById('leftButton');
|
|
const downButton = document.getElementById('downButton');
|
|
const rightButton = document.getElementById('rightButton');
|
|
|
|
const stackEls = {
|
|
T: document.getElementById('stackT'),
|
|
Z: document.getElementById('stackZ'),
|
|
Y: document.getElementById('stackY'),
|
|
X: document.getElementById('stackX'),
|
|
};
|
|
|
|
const stackCopyButtons = {
|
|
T: document.querySelector('[data-copy-stack="T"]'),
|
|
Z: document.querySelector('[data-copy-stack="Z"]'),
|
|
Y: document.querySelector('[data-copy-stack="Y"]'),
|
|
X: document.querySelector('[data-copy-stack="X"]'),
|
|
};
|
|
|
|
const keypadGrid = document.getElementById('keypadGrid');
|
|
const functionsGrid = document.getElementById('functionsGrid');
|
|
const trigoGrid = document.getElementById('trigoGrid');
|
|
const calculatorEl = document.querySelector('.calculator');
|
|
const statusLine = document.getElementById('statusLine');
|
|
|
|
const keypadKeys = [
|
|
{ label: 'ENTER', action: 'enter', className: 'key-enter' },
|
|
{ label: '⎋', action: 'escape', className: 'key-escape' },
|
|
{ label: 'C', action: 'clear', className: 'key-danger' },
|
|
{ label: '⌫', action: 'backspace', className: 'key-danger' },
|
|
{ label: '7', input: '7', className: 'key-default' },
|
|
{ label: '8', input: '8', className: 'key-default' },
|
|
{ label: '9', input: '9', className: 'key-default' },
|
|
{ label: '/', action: 'div', className: 'key-accent' },
|
|
{ label: '4', input: '4', className: 'key-default' },
|
|
{ label: '5', input: '5', className: 'key-default' },
|
|
{ label: '6', input: '6', className: 'key-default' },
|
|
{ label: '*', action: 'mul', className: 'key-accent' },
|
|
{ label: '1', input: '1', className: 'key-default' },
|
|
{ label: '2', input: '2', className: 'key-default' },
|
|
{ label: '3', input: '3', className: 'key-default' },
|
|
{ label: '-', action: 'sub', className: 'key-accent' },
|
|
{ label: '0', input: '0', className: 'key-default' },
|
|
{ label: '.', input: '.', className: 'key-default' },
|
|
{ label: '±', action: 'neg', className: 'key-default' },
|
|
{ label: '+', action: 'add', className: 'key-accent' },
|
|
];
|
|
|
|
const functionKeys = [
|
|
{ label: 'x²', action: 'sqr', className: 'key-default' },
|
|
{ label: 'yˣ', action: 'pow', className: 'key-default' },
|
|
{ label: '1/x', action: 'recip', className: 'key-default' },
|
|
{ label: '%', action: 'mod', className: 'key-default' },
|
|
{ label: '√x', action: 'sqrt', className: 'key-default' },
|
|
{ label: 'y√x', action: 'pow', className: 'key-default' },
|
|
{ label: '10ˣ', action: 'pow10', className: 'key-default' },
|
|
{ label: '', spacer: true },
|
|
{ label: 'log', action: 'log', className: 'key-default' },
|
|
{ label: 'ln', action: 'ln', className: 'key-default' },
|
|
{ label: 'eˣ', action: 'exp', className: 'key-default' },
|
|
{ label: '', spacer: true },
|
|
];
|
|
|
|
const trigoKeys = [
|
|
{ label: 'sin', action: 'sin', className: 'key-default' },
|
|
{ label: 'cos', action: 'cos', className: 'key-default' },
|
|
{ label: 'tan', action: 'tan', className: 'key-default' },
|
|
{ label: '', spacer: true },
|
|
{ label: 'asin', action: 'asin', className: 'key-default' },
|
|
{ label: 'acos', action: 'acos', className: 'key-default' },
|
|
{ label: 'atan', action: 'atan', className: 'key-default' },
|
|
{ label: '', spacer: true },
|
|
];
|
|
|
|
|
|
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window;
|
|
|
|
function focusInput() {
|
|
if (!hiddenInput || isTouchDevice) return;
|
|
hiddenInput.focus({ preventScroll: true });
|
|
window.requestAnimationFrame(() => {
|
|
if (document.activeElement !== hiddenInput) {
|
|
hiddenInput.focus({ preventScroll: true });
|
|
}
|
|
if (typeof hiddenInput.select === 'function') {
|
|
hiddenInput.select();
|
|
}
|
|
});
|
|
}
|
|
|
|
let statusTimer = null;
|
|
|
|
function setStatus(message, isError = false, timeoutMs = 1400) {
|
|
if (!statusLine) return;
|
|
clearTimeout(statusTimer);
|
|
statusLine.textContent = message;
|
|
statusLine.classList.toggle('is-error', isError);
|
|
statusLine.classList.toggle('is-visible', Boolean(message));
|
|
if (!message || timeoutMs <= 0) return;
|
|
statusTimer = window.setTimeout(() => {
|
|
statusLine.textContent = '';
|
|
statusLine.classList.remove('is-visible');
|
|
statusLine.classList.remove('is-error');
|
|
}, timeoutMs);
|
|
}
|
|
|
|
function clearStatus() {
|
|
clearTimeout(statusTimer);
|
|
statusTimer = null;
|
|
if (!statusLine) return;
|
|
statusLine.textContent = '';
|
|
statusLine.classList.remove('is-visible');
|
|
statusLine.classList.remove('is-error');
|
|
}
|
|
|
|
function normalizeStack() {
|
|
while (calc.stack.length > 4) {
|
|
calc.stack.shift();
|
|
}
|
|
}
|
|
|
|
function getStackLine(indexFromTop) {
|
|
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
|
|
}
|
|
|
|
function getStackDisplayValue(label) {
|
|
if (label === 'X') {
|
|
return calc.isEditing ? calc.inputValue : (calc.formatNumber(getStackLine(0)) || '');
|
|
}
|
|
if (label === 'Y') {
|
|
return calc.isEditing ? (calc.formatNumber(getStackLine(0)) || '') : (calc.formatNumber(getStackLine(1)) || '');
|
|
}
|
|
if (label === 'Z') {
|
|
return calc.isEditing ? (calc.formatNumber(getStackLine(1)) || '') : (calc.formatNumber(getStackLine(2)) || '');
|
|
}
|
|
return calc.isEditing ? (calc.formatNumber(getStackLine(2)) || '') : (calc.formatNumber(getStackLine(3)) || '');
|
|
}
|
|
|
|
function updateCopyButtons() {
|
|
for (const label of ['T', 'Z', 'Y', 'X']) {
|
|
const value = getStackDisplayValue(label);
|
|
const button = stackCopyButtons[label];
|
|
if (!button) continue;
|
|
button.classList.toggle('is-visible', Boolean(value));
|
|
button.disabled = !value;
|
|
button.setAttribute('aria-hidden', value ? 'false' : 'true');
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
normalizeStack();
|
|
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
|
|
calculatorEl?.classList.toggle('portrait', isPortrait);
|
|
calculatorEl?.classList.toggle('landscape', !isPortrait);
|
|
stackEls.X.textContent = getStackDisplayValue('X');
|
|
stackEls.Y.textContent = getStackDisplayValue('Y');
|
|
stackEls.Z.textContent = getStackDisplayValue('Z');
|
|
stackEls.T.textContent = getStackDisplayValue('T');
|
|
updateCopyButtons();
|
|
modeButton.textContent = calc.angleMode;
|
|
}
|
|
|
|
function pushEditingValueIfNeeded() {
|
|
if (!calc.isEditing) return;
|
|
if (calc.inputValue !== '') {
|
|
calc.push(calc.parseInputValue(calc.inputValue));
|
|
}
|
|
calc.inputValue = '';
|
|
calc.isEditing = false;
|
|
}
|
|
|
|
function inputToX(value) {
|
|
if (!calc.isEditing) {
|
|
calc.isEditing = true;
|
|
calc.inputValue = '';
|
|
}
|
|
if (value === 'Backspace') {
|
|
calc.inputValue = calc.inputValue.slice(0, -1);
|
|
} else {
|
|
calc.inputValue += value;
|
|
}
|
|
if (calc.inputValue === '') {
|
|
calc.isEditing = false;
|
|
}
|
|
}
|
|
|
|
function pasteTextIntoStack(text) {
|
|
if (!text) {
|
|
setStatus('Clipboard empty');
|
|
return;
|
|
}
|
|
const value = calc.parseInputValue(text);
|
|
if (!Number.isFinite(value)) {
|
|
setStatus('Clipboard is not a number');
|
|
return;
|
|
}
|
|
pushEditingValueIfNeeded();
|
|
calc.push(value);
|
|
calc.isEditing = false;
|
|
calc.inputValue = '';
|
|
setStatus('Pasted');
|
|
render();
|
|
}
|
|
|
|
function execute(name) {
|
|
try {
|
|
if (name === 'enter') {
|
|
if (calc.isEditing) {
|
|
pushEditingValueIfNeeded();
|
|
} else if (calc.isValidIndex(0)) {
|
|
calc.push(calc.stack[0]);
|
|
}
|
|
} else if (name === 'clear') {
|
|
calc.clear();
|
|
calc.inputValue = '';
|
|
calc.isEditing = false;
|
|
} else if (name === 'escape') {
|
|
calc.inputValue = '';
|
|
calc.isEditing = false;
|
|
} else if (name === 'backspace') {
|
|
if (calc.isEditing) {
|
|
inputToX('Backspace');
|
|
} else if (calc.isValidIndex(0)) {
|
|
calc.remove(0);
|
|
}
|
|
|
|
} else if (name === 'swap') {
|
|
pushEditingValueIfNeeded();
|
|
if (calc.isValidIndex(1)) {
|
|
calc.swap(0, 1);
|
|
}
|
|
} else if (name === 'neg') {
|
|
if (calc.isEditing) {
|
|
calc.inputValue = calc.inputValue.startsWith('-') ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
|
|
} else {
|
|
calc.push(calc.pop() * -1);
|
|
}
|
|
} else if (name === 'pow10') {
|
|
pushEditingValueIfNeeded();
|
|
calc.push(10);
|
|
calc.command('pow');
|
|
} else if (name === 'exp') {
|
|
pushEditingValueIfNeeded();
|
|
calc.push(Math.E);
|
|
calc.command('pow');
|
|
} else {
|
|
pushEditingValueIfNeeded();
|
|
calc.command(name);
|
|
}
|
|
render();
|
|
} catch (error) {
|
|
setStatus(error?.message || 'Operation error', true);
|
|
}
|
|
}
|
|
|
|
function createKeyButton({ label, input, action, spacer, className }) {
|
|
if (spacer) {
|
|
const div = document.createElement('div');
|
|
return div;
|
|
}
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.textContent = label;
|
|
button.className = className;
|
|
button.addEventListener('click', () => {
|
|
if (input) {
|
|
inputToX(input);
|
|
render();
|
|
return;
|
|
}
|
|
execute(action);
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function buildGrid(container, keys) {
|
|
container.innerHTML = '';
|
|
keys.forEach((key) => container.appendChild(createKeyButton(key)));
|
|
}
|
|
|
|
async function copyStackValue(label) {
|
|
const value = getStackDisplayValue(label);
|
|
if (!value) return;
|
|
try {
|
|
await navigator.clipboard.writeText(value);
|
|
clearStatus();
|
|
} catch (error) {
|
|
setStatus('Copy unavailable', true);
|
|
}
|
|
}
|
|
|
|
function handleKeyboard(event) {
|
|
if (event.defaultPrevented) return;
|
|
const key = event.key;
|
|
if (/^[0-9.]$/.test(key)) {
|
|
event.preventDefault();
|
|
inputToX(key);
|
|
render();
|
|
return;
|
|
}
|
|
const map = {
|
|
Enter: 'enter',
|
|
Backspace: 'backspace',
|
|
Escape: 'escape',
|
|
Delete: 'clear',
|
|
'+': 'add',
|
|
'-': 'sub',
|
|
'*': 'mul',
|
|
'/': 'div',
|
|
'%': 'mod',
|
|
'^': 'pow',
|
|
};
|
|
const arrowMap = {
|
|
ArrowUp: upButton,
|
|
ArrowDown: downButton,
|
|
ArrowLeft: leftButton,
|
|
ArrowRight: rightButton,
|
|
};
|
|
if (arrowMap[key]) {
|
|
event.preventDefault();
|
|
arrowMap[key].click();
|
|
return;
|
|
}
|
|
if (map[key]) {
|
|
event.preventDefault();
|
|
execute(map[key]);
|
|
}
|
|
}
|
|
|
|
const modeOptions = ['deg', 'rad', 'grad'];
|
|
let modeMenuEl = null;
|
|
|
|
function closeModeMenu() {
|
|
if (modeMenuEl) {
|
|
modeMenuEl.remove();
|
|
modeMenuEl = null;
|
|
}
|
|
}
|
|
|
|
function openModeMenu() {
|
|
closeModeMenu();
|
|
const rect = modeButton.getBoundingClientRect();
|
|
modeMenuEl = document.createElement('div');
|
|
modeMenuEl.className = 'mode-menu';
|
|
modeMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`;
|
|
modeMenuEl.style.left = `${rect.left + window.scrollX}px`;
|
|
modeOptions.forEach((mode) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = `mode-menu-item${mode === calc.angleMode ? ' is-active' : ''}`;
|
|
button.textContent = mode;
|
|
button.addEventListener('click', () => {
|
|
calc.angleMode = mode;
|
|
render();
|
|
closeModeMenu();
|
|
});
|
|
modeMenuEl.appendChild(button);
|
|
});
|
|
document.body.appendChild(modeMenuEl);
|
|
const menuRect = modeMenuEl.getBoundingClientRect();
|
|
const maxLeft = Math.max(8, window.innerWidth - menuRect.width - 8);
|
|
modeMenuEl.style.left = `${Math.max(8, Math.min(maxLeft, rect.left + window.scrollX))}px`;
|
|
}
|
|
|
|
modeButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
openModeMenu();
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
closeModeMenu();
|
|
closeConstMenu();
|
|
});
|
|
window.addEventListener('scroll', () => {
|
|
closeModeMenu();
|
|
closeConstMenu();
|
|
}, true);
|
|
|
|
window.addEventListener('click', (event) => {
|
|
const stackCopyButton = event.target.closest('.stack-copy-button');
|
|
if (stackCopyButton) {
|
|
const label = stackCopyButton.dataset.copyStack;
|
|
if (label) copyStackValue(label);
|
|
return;
|
|
}
|
|
if (modeMenuEl && !event.target.closest('.mode-menu') && event.target !== modeButton) {
|
|
closeModeMenu();
|
|
}
|
|
if (constMenuEl && !event.target.closest('.mode-menu') && event.target !== constButton) {
|
|
closeConstMenu();
|
|
}
|
|
});
|
|
|
|
pasteButton.addEventListener('click', async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
pasteTextIntoStack(text);
|
|
clearStatus();
|
|
} catch (error) {
|
|
setStatus('Paste unavailable', true);
|
|
}
|
|
});
|
|
|
|
hiddenInput.addEventListener('paste', (event) => {
|
|
event.preventDefault();
|
|
const text = event.clipboardData?.getData('text') ?? '';
|
|
pasteTextIntoStack(text);
|
|
});
|
|
|
|
upButton.addEventListener('click', () => {});
|
|
|
|
const constants = [
|
|
{ label: 'π', value: Math.PI },
|
|
{ label: 'e', value: Math.E },
|
|
{ label: 'φ', value: (1 + Math.sqrt(5)) / 2 },
|
|
{ label: 'g', value: 9.80665 },
|
|
{ label: 'c', value: 299792458 },
|
|
];
|
|
|
|
let constMenuEl = null;
|
|
|
|
function closeConstMenu() {
|
|
if (constMenuEl) {
|
|
constMenuEl.remove();
|
|
constMenuEl = null;
|
|
}
|
|
}
|
|
|
|
function openConstMenu() {
|
|
closeConstMenu();
|
|
const rect = constButton.getBoundingClientRect();
|
|
constMenuEl = document.createElement('div');
|
|
constMenuEl.className = 'mode-menu';
|
|
constMenuEl.style.top = `${rect.bottom + 6 + window.scrollY}px`;
|
|
constMenuEl.style.left = `${rect.left + window.scrollX}px`;
|
|
constants.forEach((constant) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'mode-menu-item';
|
|
button.textContent = constant.label;
|
|
button.addEventListener('click', () => {
|
|
pushEditingValueIfNeeded();
|
|
calc.push(constant.value);
|
|
render();
|
|
clearStatus();
|
|
closeConstMenu();
|
|
focusInput();
|
|
});
|
|
constMenuEl.appendChild(button);
|
|
});
|
|
document.body.appendChild(constMenuEl);
|
|
const menuRect = constMenuEl.getBoundingClientRect();
|
|
const maxLeft = Math.max(8, window.innerWidth - menuRect.width - 8);
|
|
constMenuEl.style.left = `${Math.max(8, Math.min(maxLeft, rect.left + window.scrollX))}px`;
|
|
}
|
|
|
|
constButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
openConstMenu();
|
|
});
|
|
|
|
leftButton.addEventListener('click', () => {});
|
|
|
|
downButton.addEventListener('click', () => {
|
|
if (!calc.isEditing && calc.isValidIndex(0)) {
|
|
const value = calc.stack[0];
|
|
calc.remove(0);
|
|
calc.isEditing = true;
|
|
calc.inputValue = calc.formatNumber(value);
|
|
render();
|
|
focusInput();
|
|
}
|
|
});
|
|
|
|
|
|
rightButton.addEventListener('click', () => {
|
|
execute('swap');
|
|
});
|
|
|
|
|
|
window.addEventListener('keydown', handleKeyboard, { capture: true });
|
|
window.addEventListener('load', focusInput);
|
|
window.addEventListener('pageshow', focusInput);
|
|
window.addEventListener('focus', focusInput);
|
|
window.addEventListener('pointerdown', focusInput, true);
|
|
window.addEventListener('mousedown', focusInput, true);
|
|
window.addEventListener('click', focusInput, true);
|
|
|
|
hiddenInput.setAttribute('inputmode', 'none');
|
|
hiddenInput.setAttribute('readonly', 'readonly');
|
|
hiddenInput.addEventListener('focus', () => {
|
|
if (isTouchDevice) {
|
|
hiddenInput.blur();
|
|
return;
|
|
}
|
|
window.requestAnimationFrame(() => {
|
|
hiddenInput.select();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('click', (event) => {
|
|
if (!isTouchDevice && !event.target.closest('.mode-menu')) {
|
|
focusInput();
|
|
}
|
|
});
|
|
|
|
buildGrid(keypadGrid, keypadKeys);
|
|
buildGrid(functionsGrid, functionKeys);
|
|
buildGrid(trigoGrid, trigoKeys);
|
|
render();
|
|
focusInput();
|