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>
552 lines
19 KiB
JavaScript
552 lines
19 KiB
JavaScript
/* 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 };
|
||
})();
|