Finanzmanager/public/static/rechner.js
fds2610-bot d1db40fa55 Fix #126: Taschenrechner an den Betragsfeldern
Jedes Zahlenfeld (inputmode="decimal") zeigt rechts ein Rechnersymbol.
Klick darauf oder Alt+R öffnet einen Taschenrechner mit Grundrechenarten
und Klammern, komplett per Tastatur bedienbar: Ziffern, + - * / ( ),
Backspace, Entf, Pfeiltasten, C löscht alles, = setzt das Ergebnis ein,
Enter überträgt ins Feld, Escape schließt und fokussiert das Feld. Das
Ergebnis steht nach jedem Tastendruck unter der Rechnung.

Das Symbol ist ein Hintergrundbild (kein Wrapper-Element, kein Eingriff in
die Layouts); gerechnet wird mit eigenem Parser statt eval. In modalen
Dialogen wandert der Rechner mit hinein. Stück/Kurs übernehmen 6,
Zinssätze 3 Nachkommastellen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:22:18 +02:00

552 lines
19 KiB
JavaScript
Raw Permalink 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.

/* Taschenrechner an den Betragsfeldern (Fix #126).
*
* Jedes Zahlenfeld (`inputmode="decimal"`) trägt rechts ein Rechnersymbol. Das
* Symbol ist nur ein Hintergrundbild aus app.css: Ein eigenes Element um das
* Feld griffe in Dutzende Layouts ein – Grids, Tabellenzellen, schmale
* Inline-Felder. Den Klick darauf erkennt dieses Skript an der Position.
* Alt+R öffnet den Rechner auch von der Tastatur aus.
*
* Der Rechner ist mit der Tastatur bedienbar: Ziffern und + - * / ( ) tippen,
* Backspace/Entf/Pfeiltasten wie gewohnt, C löscht alles, = setzt das Ergebnis
* als neue Rechnung ein, Enter überträgt es ins Feld, Escape schließt. Das
* Ergebnis steht nach jedem Tastendruck unter der Rechnung.
*
* Gerechnet wird mit einem eigenen kleinen Parser – `eval` verböte die
* Content-Security-Policy, und auf Eingaben gehört es ohnehin nicht.
*
* Felder mit `data-ohne-rechner` bleiben außen vor. `data-nachkommastellen`
* legt fest, wie genau das Ergebnis übertragen wird (Vorgabe: 2).
*/
(function () {
'use strict';
const AUSWAHL = 'input[type="text"][inputmode="decimal"]:not([data-ohne-rechner])';
/* Was die Tastatur liefert, wie es in der Anzeige erscheint. */
const ZEICHEN = {
'0': '0', '1': '1', '2': '2', '3': '3', '4': '4',
'5': '5', '6': '6', '7': '7', '8': '8', '9': '9',
'+': '+', '-': '−', '*': '×', '/': '÷', '(': '(', ')': ')',
',': ',', '.': ',',
'−': '−', '×': '×', '÷': '÷', ':': '÷', 'x': '×'
};
/* Das Tastenfeld, zeilenweise. */
const TASTEN = [
['C', '(', ')', '⌫'],
['7', '8', '9', '÷'],
['4', '5', '6', '×'],
['1', '2', '3', '−'],
['0', ',', '=', '+']
];
const OPERATOREN = ['÷', '×', '−', '+', '(', ')'];
/* ── Rechnen ─────────────────────────────────────────────────────────── */
function RechenFehler(art) {
this.art = art;
}
/* Rechnung → Zahl. Wirft RechenFehler mit der Art „unvollstaendig“ (die
* Eingabe hört mitten in der Rechnung auf), „ungueltig“ oder „null“
* (Division durch null). */
function auswerten(text) {
const s = text
.replace(/\s+/g, '')
.replace(/×/g, '*')
.replace(/÷/g, '/')
.replace(/−/g, '-')
.replace(/,/g, '.');
let pos = 0;
function ende() {
return pos >= s.length;
}
function zahl() {
const treffer = /^(\d+\.?\d*|\.\d+|\.)/.exec(s.slice(pos));
if (!treffer) {
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
}
pos += treffer[0].length;
if (treffer[0] === '.') {
throw new RechenFehler(ende() ? 'unvollstaendig' : 'ungueltig');
}
return parseFloat(treffer[0]);
}
function faktor() {
if (ende()) {
throw new RechenFehler('unvollstaendig');
}
const z = s[pos];
if (z === '+' || z === '-') {
pos++;
const wert = faktor();
return z === '-' ? -wert : wert;
}
if (z === '(') {
pos++;
const wert = ausdruck();
if (ende()) {
throw new RechenFehler('unvollstaendig');
}
if (s[pos] !== ')') {
throw new RechenFehler('ungueltig');
}
pos++;
return wert;
}
return zahl();
}
function term() {
let wert = faktor();
while (!ende() && (s[pos] === '*' || s[pos] === '/')) {
const op = s[pos++];
const rechts = faktor();
if (op === '/' && rechts === 0) {
throw new RechenFehler('null');
}
wert = op === '*' ? wert * rechts : wert / rechts;
}
return wert;
}
function ausdruck() {
let wert = term();
while (!ende() && (s[pos] === '+' || s[pos] === '-')) {
const op = s[pos++];
const rechts = term();
wert = op === '+' ? wert + rechts : wert - rechts;
}
return wert;
}
const wert = ausdruck();
if (!ende()) {
throw new RechenFehler('ungueltig');
}
// Gleitkommareste wie 0,1 + 0,2 = 0,30000000000000004 abschneiden.
return Math.round(wert * 1e10) / 1e10;
}
/* Für die Anzeige im Rechner: so viele Stellen wie nötig, mit Tausenderpunkt. */
function anzeigeText(wert) {
return wert.toLocaleString('de-DE', { maximumFractionDigits: 10 });
}
/* Für das Feld: ohne Tausenderpunkt, wie die Felder ihre Werte vom Server
* bekommen (number_format(…, 2, ',', '')). */
function feldText(wert, stellen) {
return wert.toLocaleString('de-DE', {
minimumFractionDigits: Math.min(2, stellen),
maximumFractionDigits: stellen,
useGrouping: false
});
}
/* Der bisherige Feldinhalt als Startwert der Rechnung. */
function startwert(feld) {
const text = feld.value.trim();
if (text === '') {
return '';
}
const wert = window.FmBetrag
? window.FmBetrag.zahl(text)
: parseFloat(text.replace(/\./g, '').replace(',', '.')) || 0;
if (wert === 0) {
return '';
}
return wert
.toLocaleString('de-DE', { maximumFractionDigits: 10, useGrouping: false })
.replace('-', '−');
}
/* ── Oberfläche ──────────────────────────────────────────────────────── */
const mitPopover = typeof HTMLElement.prototype.showPopover === 'function';
let rechner = null;
let eingabe = null;
let ergebnis = null;
let offenesFeld = null;
let letzterWert = null;
function bauen() {
rechner = document.createElement('div');
rechner.className = 'rechner';
rechner.setAttribute('role', 'dialog');
rechner.setAttribute('aria-label', 'Taschenrechner');
if (mitPopover) {
rechner.popover = 'manual';
} else {
rechner.hidden = true;
}
const anzeige = document.createElement('div');
anzeige.className = 'rechner-anzeige';
eingabe = document.createElement('input');
eingabe.type = 'text';
eingabe.className = 'rechner-eingabe';
eingabe.autocomplete = 'off';
eingabe.spellcheck = false;
eingabe.setAttribute('inputmode', 'none');
eingabe.setAttribute('aria-label', 'Rechnung');
ergebnis = document.createElement('output');
ergebnis.className = 'rechner-ergebnis';
ergebnis.setAttribute('aria-live', 'polite');
anzeige.append(eingabe, ergebnis);
const tasten = document.createElement('div');
tasten.className = 'rechner-tasten';
TASTEN.flat().forEach(function (taste) {
tasten.appendChild(knopf(taste, taste));
});
const uebernehmen = knopf('Übernehmen', 'Enter');
uebernehmen.classList.add('uebernehmen');
uebernehmen.title = 'Ergebnis ins Feld übernehmen (Enter)';
tasten.appendChild(uebernehmen);
rechner.append(anzeige, tasten);
eingabe.addEventListener('keydown', tastendruck);
eingabe.addEventListener('input', bereinigen);
rechner.addEventListener('focusout', function (ereignis) {
// Mit Tab hinaus: zu. Ohne Ziel (Fensterwechsel) bleibt er offen.
if (ereignis.relatedTarget && !rechner.contains(ereignis.relatedTarget)) {
schliessen(false);
}
});
}
function knopf(beschriftung, taste) {
const k = document.createElement('button');
k.type = 'button';
k.tabIndex = -1;
k.textContent = beschriftung;
k.dataset.taste = taste;
if (OPERATOREN.indexOf(taste) !== -1) {
k.classList.add('operator');
}
if (taste === 'C' || taste === '⌫') {
k.classList.add('loeschen');
}
if (taste === '⌫') {
k.setAttribute('aria-label', 'Zeichen löschen');
}
// Der Fokus bleibt in der Rechnung, sonst stünde die Schreibmarke
// nach jedem Klick woanders.
k.addEventListener('mousedown', function (ereignis) {
ereignis.preventDefault();
});
k.addEventListener('click', function () {
taste === 'Enter' ? uebernehmen() : drueckeTaste(taste);
eingabe.focus();
});
return k;
}
function einfuegen(text) {
eingabe.setRangeText(text, eingabe.selectionStart, eingabe.selectionEnd, 'end');
rechnen();
}
function drueckeTaste(taste) {
if (taste === 'C') {
eingabe.value = '';
rechnen();
} else if (taste === '⌫') {
const start = eingabe.selectionStart;
const ende = eingabe.selectionEnd;
if (start !== ende) {
eingabe.setRangeText('', start, ende, 'end');
} else if (start > 0) {
eingabe.setRangeText('', start - 1, start, 'end');
}
rechnen();
} else if (taste === '=') {
ergebnisEinsetzen();
} else {
einfuegen(taste);
}
}
/* „=“: Die Rechnung weicht ihrem Ergebnis, weitergerechnet wird damit. */
function ergebnisEinsetzen() {
if (letzterWert === null || eingabe.value.trim() === '') {
return;
}
eingabe.value = feldText(letzterWert, 10).replace('-', '−');
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
rechnen();
}
function tastendruck(ereignis) {
const taste = ereignis.key;
// Kopieren, Einfügen, Alles markieren bleiben, wie sie sind.
if (ereignis.ctrlKey || ereignis.metaKey) {
return;
}
if (taste === 'Enter') {
ereignis.preventDefault();
ereignis.stopPropagation();
uebernehmen();
return;
}
if (taste === 'Escape') {
// Nicht weiterreichen: Sonst schlösse Escape auch einen Dialog,
// in dem das Feld steht, oder das Kontoblatt verwürfe eine Auswahl.
ereignis.preventDefault();
ereignis.stopPropagation();
schliessen(true);
return;
}
if (taste === 'c' || taste === 'C') {
ereignis.preventDefault();
drueckeTaste('C');
return;
}
if (taste === '=') {
ereignis.preventDefault();
ergebnisEinsetzen();
return;
}
if (Object.prototype.hasOwnProperty.call(ZEICHEN, taste)) {
ereignis.preventDefault();
einfuegen(ZEICHEN[taste]);
return;
}
// Backspace, Entf, Pfeile, Pos1/Ende, Tab: wie in jedem Textfeld.
// Jedes andere druckbare Zeichen gehört nicht in eine Rechnung.
if (taste.length === 1 && !ereignis.altKey) {
ereignis.preventDefault();
}
}
/* Nach Einfügen aus der Zwischenablage: nur übernehmen, was rechnen kann. */
function bereinigen() {
const vorher = eingabe.value;
let marke = eingabe.selectionStart;
let sauber = '';
for (let i = 0; i < vorher.length; i++) {
const z = vorher[i];
if (Object.prototype.hasOwnProperty.call(ZEICHEN, z)) {
sauber += ZEICHEN[z];
} else if (i < marke) {
marke--;
}
}
if (sauber !== vorher) {
eingabe.value = sauber;
eingabe.setSelectionRange(marke, marke);
}
rechnen();
}
function rechnen() {
const text = eingabe.value;
ergebnis.classList.remove('veraltet', 'fehler');
if (text.trim() === '') {
letzterWert = null;
ergebnis.textContent = '0';
ergebnis.classList.add('veraltet');
return;
}
try {
letzterWert = auswerten(text);
ergebnis.textContent = anzeigeText(letzterWert);
} catch (fehler) {
if (!(fehler instanceof RechenFehler)) {
throw fehler;
}
letzterWert = null;
if (fehler.art === 'unvollstaendig') {
// Mitten in der Rechnung: das zuletzt gültige Ergebnis
// gedämpft stehen lassen, statt bei jedem Operator zu flackern.
ergebnis.classList.add('veraltet');
} else {
ergebnis.classList.add('fehler');
ergebnis.textContent = fehler.art === 'null'
? 'Division durch 0'
: 'Ungültige Rechnung';
}
}
}
function uebernehmen() {
const feld = offenesFeld;
if (!feld) {
return;
}
if (eingabe.value.trim() !== '') {
if (letzterWert === null) {
// Unvollständig oder fehlerhaft: nichts übertragen.
if (!ergebnis.classList.contains('fehler')) {
ergebnis.textContent = 'Rechnung unvollständig';
}
ergebnis.classList.remove('veraltet');
ergebnis.classList.add('fehler');
return;
}
const stellen = parseInt(feld.dataset.nachkommastellen || '2', 10);
feld.value = feldText(letzterWert, isNaN(stellen) ? 2 : stellen);
// Restbeträge, Depotberechnung, „Speichern“-Knöpfe hängen an diesen
// Ereignissen – sie sollen den neuen Wert sehen wie einen getippten.
feld.dispatchEvent(new Event('input', { bubbles: true }));
feld.dispatchEvent(new Event('change', { bubbles: true }));
}
schliessen(true);
}
function positionieren() {
if (!offenesFeld) {
return;
}
const r = offenesFeld.getBoundingClientRect();
const breite = rechner.offsetWidth;
const hoehe = rechner.offsetHeight;
const rand = 8;
let links = Math.min(r.right - breite, window.innerWidth - breite - rand);
links = Math.max(rand, links);
let oben = r.bottom + 4;
if (oben + hoehe > window.innerHeight - rand && r.top - hoehe - 4 >= rand) {
oben = r.top - hoehe - 4;
}
rechner.style.left = links + 'px';
rechner.style.top = oben + 'px';
}
function istOffen() {
return offenesFeld !== null;
}
function oeffnen(feld) {
if (!rechner) {
bauen();
}
if (istOffen()) {
schliessen(false);
}
// In einem modalen Dialog wäre alles außerhalb inaktiv – der Rechner
// muss deshalb mit in den Dialog.
const heimat = feld.closest('dialog') || document.body;
if (rechner.parentNode !== heimat) {
heimat.appendChild(rechner);
}
offenesFeld = feld;
eingabe.value = startwert(feld);
ergebnis.textContent = '';
rechnen();
if (mitPopover) {
rechner.showPopover();
} else {
rechner.hidden = false;
}
positionieren();
eingabe.focus();
eingabe.setSelectionRange(eingabe.value.length, eingabe.value.length);
}
/* `fokus`: das Feld wieder in den Fokus nehmen (Enter, Escape). */
function schliessen(fokus) {
const feld = offenesFeld;
if (!feld) {
return;
}
offenesFeld = null;
if (mitPopover) {
if (rechner.matches(':popover-open')) {
rechner.hidePopover();
}
} else {
rechner.hidden = true;
}
if (fokus) {
feld.focus();
}
feld.dispatchEvent(new CustomEvent('rechner:zu', { detail: { fokus: fokus } }));
}
/* ── Anbindung an die Felder ─────────────────────────────────────────── */
function bedienbar(feld) {
return feld instanceof HTMLInputElement
&& feld.matches(AUSWAHL)
&& !feld.readOnly
&& !feld.disabled
&& !feld.hidden;
}
/* Liegt der Zeiger über dem Symbol? Die Fläche ist der rechte Innenabstand,
* den app.css für das Symbol freihält. */
function aufSymbol(feld, ereignis) {
const stil = getComputedStyle(feld);
const zone = parseFloat(stil.paddingRight) + parseFloat(stil.borderRightWidth);
const r = feld.getBoundingClientRect();
return ereignis.clientX >= r.right - zone && ereignis.clientX <= r.right
&& ereignis.clientY >= r.top && ereignis.clientY <= r.bottom;
}
document.addEventListener('mousemove', function (ereignis) {
const feld = ereignis.target;
if (feld instanceof HTMLInputElement && bedienbar(feld)) {
const drauf = aufSymbol(feld, ereignis);
feld.classList.toggle('rechner-zeiger', drauf);
// Der Hinweis nur über dem Symbol – und nie anstelle eines eigenen.
if (drauf && !feld.title) {
feld.title = 'Taschenrechner (Alt+R)';
feld.dataset.rechnerTitel = '';
} else if (!drauf && feld.dataset.rechnerTitel !== undefined) {
feld.removeAttribute('title');
delete feld.dataset.rechnerTitel;
}
}
});
document.addEventListener('mousedown', function (ereignis) {
const ziel = ereignis.target;
if (ziel instanceof HTMLInputElement && bedienbar(ziel) && aufSymbol(ziel, ereignis)) {
// Kein Fokus, keine Schreibmarke im Feld – der Klick gilt dem Symbol.
ereignis.preventDefault();
if (offenesFeld === ziel) {
schliessen(true);
} else {
oeffnen(ziel);
}
return;
}
// Klick daneben: zu, ohne dem Klick den Fokus streitig zu machen.
if (istOffen() && ziel instanceof Node && !rechner.contains(ziel)) {
schliessen(false);
}
});
document.addEventListener('keydown', function (ereignis) {
if (ereignis.altKey && !ereignis.ctrlKey && !ereignis.metaKey && ereignis.code === 'KeyR'
&& ereignis.target instanceof HTMLInputElement && bedienbar(ereignis.target)) {
ereignis.preventDefault();
oeffnen(ereignis.target);
}
});
window.addEventListener('resize', positionieren);
// Mitlaufen, wenn die Seite oder ein Dialog darunter scrollt.
document.addEventListener('scroll', positionieren, true);
window.FmRechner = { auswerten: auswerten, oeffnen: oeffnen };
})();