/* 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 }; })();