62a0f447c5
Add titles for function and trig buttons to expose keyboard hints, and remap reciprocal/power10 shortcuts to match the new key layout. Update the portrait visual spec to reflect the revised keypad and function ordering.
586 lines
16 KiB
JavaScript
586 lines
16 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: '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: 'v' },
|
||
{ 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: 'u' },
|
||
{ 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;
|
||
|
||
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 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);
|
||
stackEls.X.textContent = calc.isEditing ? '' : getStackDisplayValue('X');
|
||
if (calc.isEditing) {
|
||
renderEditValue();
|
||
}
|
||
stackEls.Y.textContent = getStackDisplayValue('Y');
|
||
stackEls.Z.textContent = getStackDisplayValue('Z');
|
||
stackEls.T.textContent = getStackDisplayValue('T');
|
||
stackEls.X.classList.toggle('is-editing', calc.isEditing);
|
||
stackEls.X.classList.toggle('is-caret-visible', calc.isEditing);
|
||
updateCopyButtons();
|
||
modeButton.textContent = calc.angleMode;
|
||
}
|
||
|
||
function stopEditing(clearValue = false) {
|
||
if (clearValue) {
|
||
calc.inputValue = '';
|
||
}
|
||
calc.isEditing = false;
|
||
editCursor = 0;
|
||
}
|
||
|
||
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));
|
||
}
|
||
calc.inputValue = '';
|
||
calc.isEditing = false;
|
||
editCursor = 0;
|
||
}
|
||
|
||
function startEditingFromStackTop() {
|
||
if (!calc.isValidIndex(0)) return false;
|
||
const value = calc.stack[0];
|
||
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') {
|
||
stopEditing(true);
|
||
} 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 (/^[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();
|
||
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', () => {});
|
||
|
||
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();
|
||
}
|
||
});
|
||
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();
|