Files
mtm-rpn-js/samples/calc-02/index.js
T

791 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 'C', action: 'clear', className: 'key-danger' },
{ label: '⌫', action: 'backspace', className: 'key-danger' },
{ label: '⎋', action: 'escape', className: 'key-escape' },
{ label: '⏎', action: 'enter', className: 'key-enter' },
{ 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', title: 's' },
{ label: '√x', action: 'sqrt', className: 'key-default', title: 'r' },
{ label: '1/x', action: 'recip', className: 'key-default', title: 'x' },
{ label: '%', action: 'mod', className: 'key-default' },
{ label: 'yˣ', action: 'pow', className: 'key-default', title: 'S' },
{ label: 'y√x', action: 'root', className: 'key-default', title: 'R' },
{ label: '10ˣ', action: 'pow10', className: 'key-default', title: 'd' },
{ label: '', spacer: true },
{ label: 'log', action: 'log', className: 'key-default', title: 'l / L' },
{ label: 'ln', action: 'ln', className: 'key-default', title: 'n / N' },
{ label: 'eˣ', action: 'exp', className: 'key-default', title: 'e / E' },
{ label: '', spacer: true },
];
const trigoKeys = [
{ label: 'sin', action: 'sin', className: 'key-default', title: 'i' },
{ label: 'cos', action: 'cos', className: 'key-default', title: 'o' },
{ label: 'tan', action: 'tan', className: 'key-default', title: 'a' },
{ label: '', spacer: true },
{ label: 'asin', action: 'asin', className: 'key-default', title: 'I' },
{ label: 'acos', action: 'acos', className: 'key-default', title: 'O' },
{ label: 'atan', action: 'atan', className: 'key-default', title: 'A' },
{ 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;
let editCursor = 0;
let editRestoreValue = null;
let stackMode = 'normal';
let stackSelection = 0;
let stackMoveSnapshot = 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 getStackLine(indexFromTop) {
return indexFromTop >= 0 && indexFromTop < calc.stack.length ? calc.stack[indexFromTop] : '';
}
function getStackDisplayValue(label) {
if (label === 'X' && calc.isEditing) {
return calc.inputValue;
}
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
const indexFromTop = calc.isEditing ? Math.max(0, indexMap[label] - 1) : indexMap[label];
return calc.formatNumber(getStackLine(indexFromTop)) || '';
}
function getVisibleStackLabel(label) {
if (stackMode === 'navigation' || stackMode === 'move') {
const indexMap = { X: 0, Y: 1, Z: 2, T: 3 };
return String(indexMap[label]);
}
return label;
}
function updateStackLabels() {
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const stackCell = stackEls[label].parentElement;
if (!stackCell) continue;
const labelEl = stackCell.querySelector('.stack-label');
if (labelEl) {
labelEl.textContent = `${getVisibleStackLabel(label)}:`;
}
}
}
function stackModeToLabel(index) {
return ['X', 'Y', 'Z', 'T'][Math.max(0, Math.min(3, index))] ?? 'X';
}
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 renderEditValue() {
const cursor = Math.max(0, Math.min(editCursor, calc.inputValue.length));
stackEls.X.innerHTML = `<span class="edit-text">${calc.inputValue.slice(0, cursor)}</span><span class="edit-caret" aria-hidden="true"></span><span class="edit-text">${calc.inputValue.slice(cursor)}</span>`;
}
function render() {
const isPortrait = window.matchMedia('(orientation: portrait)').matches || window.innerWidth <= 860;
calculatorEl?.classList.toggle('portrait', isPortrait);
calculatorEl?.classList.toggle('landscape', !isPortrait);
const stackLabels = ['T', 'Z', 'Y', 'X'];
for (const label of stackLabels) {
const isSelected = stackMode !== 'normal' && stackModeToLabel(stackSelection) === label;
const value = label === 'X' && calc.isEditing ? '' : getStackDisplayValue(label);
stackEls[label].textContent = value;
stackEls[label].classList.toggle('is-editing', label === 'X' && calc.isEditing);
stackEls[label].classList.toggle('is-caret-visible', label === 'X' && calc.isEditing);
stackEls[label].parentElement?.classList.toggle('is-selected', isSelected);
stackEls[label].parentElement?.classList.toggle('is-moving', stackMode === 'move' && stackModeToLabel(stackSelection) === label);
}
if (calc.isEditing) {
renderEditValue();
}
updateStackLabels();
updateCopyButtons();
modeButton.textContent = calc.angleMode;
}
function stopEditing(clearValue = false) {
if (clearValue) {
calc.inputValue = '';
}
calc.isEditing = false;
editCursor = 0;
}
function cancelEditing() {
if (editRestoreValue !== null) {
calc.push(editRestoreValue);
}
editRestoreValue = null;
stopEditing(true);
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function enterNavigationMode() {
if (calc.isEditing) return;
stackMode = 'navigation';
stackSelection = 0;
render();
}
function exitStackMode() {
stackMode = 'normal';
stackSelection = 0;
stackMoveSnapshot = null;
render();
}
function moveNavigationSelection(delta) {
const maxIndex = Math.max(0, calc.stack.length - 1);
stackSelection = Math.max(0, Math.min(maxIndex, stackSelection + delta));
render();
}
function beginStackMove() {
stackMode = 'move';
stackMoveSnapshot = calc.stack.slice();
render();
}
function restoreStackMoveSnapshot() {
if (!stackMoveSnapshot) return;
calc.stack = stackMoveSnapshot.slice();
stackMoveSnapshot = null;
render();
}
function validateStackMove() {
stackMoveSnapshot = null;
stackMode = 'normal';
render();
}
function moveSelectedStackValue(delta) {
const index = stackSelection;
const target = Math.max(0, Math.min(calc.stack.length - 1, index + delta));
if (target === index) return;
const value = calc.stack.splice(index, 1)[0];
calc.stack.splice(target, 0, value);
stackSelection = target;
render();
}
function moveEditCursor(delta) {
editCursor = Math.max(0, Math.min(calc.inputValue.length, editCursor + delta));
}
function pushEditingValueIfNeeded() {
if (!calc.isEditing) return;
if (calc.inputValue !== '') {
calc.push(calc.parseInputValue(calc.inputValue));
}
editRestoreValue = null;
calc.inputValue = '';
calc.isEditing = false;
editCursor = 0;
}
function startEditingFromStackTop() {
if (!calc.isValidIndex(0)) return false;
const value = calc.stack[0];
editRestoreValue = value;
calc.remove(0);
calc.isEditing = true;
calc.inputValue = calc.formatNumber(value);
editCursor = calc.inputValue.length;
return true;
}
function inputToX(value) {
if (!calc.isEditing) {
calc.isEditing = true;
calc.inputValue = '';
editCursor = 0;
}
if (value === 'Backspace') {
if (editCursor > 0) {
calc.inputValue = `${calc.inputValue.slice(0, editCursor - 1)}${calc.inputValue.slice(editCursor)}`;
editCursor -= 1;
}
} else {
calc.inputValue = `${calc.inputValue.slice(0, editCursor)}${value}${calc.inputValue.slice(editCursor)}`;
editCursor += value.length;
}
if (calc.inputValue === '') {
stopEditing();
}
}
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();
stopEditing(true);
} else if (name === 'escape') {
cancelEditing();
} 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) {
const hasSign = calc.inputValue.startsWith('-');
calc.inputValue = hasSign ? calc.inputValue.slice(1) : `-${calc.inputValue}`;
moveEditCursor(hasSign ? -1 : 1);
} else {
calc.command('neg');
}
} else {
pushEditingValueIfNeeded();
calc.command(name);
}
if (!calc.isEditing) {
editCursor = 0;
}
render();
} catch (error) {
setStatus(error?.message || 'Operation error', true);
}
}
function createKeyButton({ label, input, action, spacer, className, title }) {
if (spacer) {
const div = document.createElement('div');
return div;
}
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.className = className;
if (title) button.title = title;
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 (stackMode === 'navigation') {
if (key === 'ArrowUp') {
event.preventDefault();
moveNavigationSelection(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveNavigationSelection(-1);
return;
}
if (key === 'Escape' || key === 'ArrowLeft') {
event.preventDefault();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
beginStackMove();
return;
}
return;
}
if (stackMode === 'move') {
if (key === 'ArrowUp') {
event.preventDefault();
moveSelectedStackValue(1);
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
moveSelectedStackValue(-1);
return;
}
if (key === 'Escape') {
event.preventDefault();
restoreStackMoveSnapshot();
exitStackMode();
return;
}
if (key === 'Enter') {
event.preventDefault();
validateStackMove();
return;
}
return;
}
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',
s: 'sqr',
S: 'pow',
r: 'sqrt',
R: 'root',
x: 'recip',
d: 'pow10',
l: 'log',
L: 'log',
n: 'ln',
N: 'ln',
e: 'exp',
E: 'exp',
i: 'sin',
o: 'cos',
a: 'tan',
I: 'asin',
O: 'acos',
A: 'atan',
};
if (key === 'ArrowLeft') {
event.preventDefault();
if (calc.isEditing) {
moveEditCursor(-1);
render();
return;
}
leftButton.click();
return;
}
if (key === 'ArrowRight') {
event.preventDefault();
if (calc.isEditing) {
moveEditCursor(1);
render();
return;
}
rightButton.click();
return;
}
if (key === 'ArrowUp') {
event.preventDefault();
upButton.click();
return;
}
if (key === 'ArrowDown') {
event.preventDefault();
if (calc.isEditing) {
cancelEditing();
render();
return;
}
downButton.click();
return;
}
if (map[key]) {
event.preventDefault();
execute(map[key]);
}
}
const modeOptions = ['deg', 'rad', 'grad'];
let activeMenuEl = null;
function closeModeMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function openMenu(anchorButton, items, onSelect) {
const rect = anchorButton.getBoundingClientRect();
const menu = document.createElement('div');
menu.className = 'menu-popup';
menu.style.top = `${rect.bottom + 6 + window.scrollY}px`;
menu.style.left = `${rect.left + window.scrollX}px`;
menu.style.minWidth = `${rect.width}px`;
for (const item of items) {
const button = document.createElement('button');
button.type = 'button';
button.className = `menu-popup-item${item.active ? ' is-active' : ''}`;
button.textContent = item.label;
button.addEventListener('click', () => onSelect(item.value));
menu.appendChild(button);
}
document.body.appendChild(menu);
return menu;
}
function toggleModeMenu() {
if (activeMenuEl) {
closeModeMenu();
return;
}
closeConstMenu();
activeMenuEl = openMenu(modeButton, modeOptions.map((mode) => ({
label: mode,
value: mode,
active: mode === calc.angleMode,
})), (mode) => {
calc.angleMode = mode;
render();
closeModeMenu();
});
}
modeButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleModeMenu();
});
window.addEventListener('resize', () => {
closeModeMenu();
closeConstMenu();
render();
});
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 (activeMenuEl && !event.target.closest('.menu-popup') && event.target !== modeButton && event.target !== constButton) {
closeModeMenu();
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', () => {
if (!calc.isEditing && stackMode === 'normal') {
enterNavigationMode();
focusInput();
}
});
const constantLabels = {
pi: 'π',
e: 'e',
phi: 'φ',
g: 'g',
c: 'C',
};
const constantOrder = ['pi', 'e', 'phi', 'g', 'c'];
function closeConstMenu() {
if (activeMenuEl) {
activeMenuEl.remove();
activeMenuEl = null;
}
}
function toggleConstMenu() {
if (activeMenuEl) {
closeConstMenu();
return;
}
closeModeMenu();
const availableConstants = calc.listConstants();
const keys = [...constantOrder, ...Object.keys(availableConstants).filter((name) => !constantOrder.includes(name))]
.filter((name) => Object.prototype.hasOwnProperty.call(availableConstants, name));
activeMenuEl = openMenu(constButton, keys.map((name) => ({
label: constantLabels[name] ?? name,
value: name,
})), (name) => {
pushEditingValueIfNeeded();
calc.push(availableConstants[name]);
render();
clearStatus();
closeConstMenu();
focusInput();
});
}
constButton.addEventListener('click', (event) => {
event.stopPropagation();
toggleConstMenu();
});
leftButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(-1);
render();
focusInput();
return;
}
if (stackMode === 'navigation') {
exitStackMode();
focusInput();
}
});
downButton.addEventListener('click', () => {
if (!calc.isEditing && startEditingFromStackTop()) {
render();
focusInput();
}
});
rightButton.addEventListener('click', () => {
if (calc.isEditing) {
moveEditCursor(1);
render();
focusInput();
return;
}
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);
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('.menu-popup')) {
focusInput();
}
});
buildGrid(keypadGrid, keypadKeys);
buildGrid(functionsGrid, functionKeys);
buildGrid(trigoGrid, trigoKeys);
render();
focusInput();