2e691b8b51
data.calculator wurde bei jedem save() komplett ersetzt, wodurch factorio/satisfactory Sub-Mode-Präferenzen verloren gingen. Jetzt werden nur die Core-Properties einzeln gesetzt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
930 lines
25 KiB
JavaScript
930 lines
25 KiB
JavaScript
/* =============================================
|
||
HELLION NEWTAB — calculator.js
|
||
Taschenrechner Widget: Expression-Parsing,
|
||
History, Tastatureingabe
|
||
============================================= */
|
||
|
||
const Calculator = {
|
||
WIDGET_ID: 'widget_calculator',
|
||
STORAGE_KEY: 'widgetStates',
|
||
MAX_HISTORY: 10,
|
||
|
||
/** @type {Array<{expr: string, result: string}>} */
|
||
_history: [],
|
||
_currentExpr: '',
|
||
_lastResult: '',
|
||
_isOpen: false,
|
||
_displayExprEl: null,
|
||
_displayResultEl: null,
|
||
_keydownHandler: null,
|
||
_modes: new Map(),
|
||
_activeMode: 'standard',
|
||
_tabBarEl: null,
|
||
|
||
// ---- MODE REGISTRY ----
|
||
|
||
/**
|
||
* Modus registrieren (wird von externen Mode-Dateien aufgerufen)
|
||
* @param {string} name - Eindeutiger Modus-Name
|
||
* @param {Object} config - { label, shortName, titleKey, render(bodyEl), destroy() }
|
||
*/
|
||
registerMode(name, config) {
|
||
this._modes.set(name, config);
|
||
// Tab-Bar aktualisieren falls Widget bereits offen
|
||
if (this._tabBarEl) this._renderTabBar();
|
||
},
|
||
|
||
// ---- STORAGE ----
|
||
|
||
/**
|
||
* Calculator-State aus Storage laden
|
||
*/
|
||
async load() {
|
||
const data = await Store.get(this.STORAGE_KEY);
|
||
if (data && data.calculator) {
|
||
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
|
||
if (data.calculator.activeMode) {
|
||
this._activeMode = data.calculator.activeMode;
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Calculator-State in Storage speichern
|
||
* Bestehende Notes-Daten bleiben erhalten
|
||
*/
|
||
async save() {
|
||
const data = await Store.get(this.STORAGE_KEY) || {};
|
||
|
||
// Widget-Position aus WidgetManager holen
|
||
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||
if (!data.calculator) data.calculator = {};
|
||
data.calculator.x = widgetState ? widgetState.x : 400;
|
||
data.calculator.y = widgetState ? widgetState.y : 120;
|
||
data.calculator.width = widgetState ? widgetState.width : 280;
|
||
data.calculator.height = widgetState ? widgetState.height : 400;
|
||
data.calculator.open = this._isOpen;
|
||
data.calculator.activeMode = this._activeMode;
|
||
data.calculator.history = this._history.slice(0, this.MAX_HISTORY);
|
||
|
||
await Store.set(this.STORAGE_KEY, data);
|
||
},
|
||
|
||
// ---- WIDGET LIFECYCLE ----
|
||
|
||
/**
|
||
* Calculator oeffnen oder in Vordergrund bringen
|
||
*/
|
||
async open() {
|
||
if (this._isOpen) {
|
||
WidgetManager.bringToFront(this.WIDGET_ID);
|
||
return;
|
||
}
|
||
|
||
// Gespeicherte Position laden
|
||
const data = await Store.get(this.STORAGE_KEY);
|
||
const saved = (data && data.calculator) ? data.calculator : {};
|
||
|
||
const widgetId = WidgetManager.create('calculator', {
|
||
id: this.WIDGET_ID,
|
||
title: t('calculator.title'),
|
||
x: saved.x || 400,
|
||
y: saved.y || 120,
|
||
width: saved.width || 280,
|
||
height: saved.height || 400,
|
||
open: true
|
||
});
|
||
|
||
const body = WidgetManager.getBody(widgetId);
|
||
if (body) this.renderBody(body);
|
||
|
||
this._isOpen = true;
|
||
|
||
// Keyboard-Events binden
|
||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||
if (entry) this._bindKeyboard(entry.el);
|
||
|
||
await this.save();
|
||
},
|
||
|
||
/**
|
||
* Calculator toggle: oeffnen oder minimieren
|
||
*/
|
||
async toggle() {
|
||
if (this._isOpen) {
|
||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||
if (entry && entry.state.open) {
|
||
await WidgetManager.minimize(this.WIDGET_ID);
|
||
this._isOpen = false;
|
||
await this.save();
|
||
} else if (entry) {
|
||
await WidgetManager.openWidget(this.WIDGET_ID);
|
||
this._isOpen = true;
|
||
await this.save();
|
||
}
|
||
} else {
|
||
await this.open();
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Wird aufgerufen wenn Widget geschlossen wird
|
||
*/
|
||
async onClose() {
|
||
// Aktiven Modus aufräumen
|
||
const mode = this._modes.get(this._activeMode);
|
||
if (mode && mode.destroy) mode.destroy();
|
||
|
||
this._isOpen = false;
|
||
this._unbindKeyboard();
|
||
this._tabBarEl = null;
|
||
this._displayExprEl = null;
|
||
this._displayResultEl = null;
|
||
await this.save();
|
||
},
|
||
|
||
// ---- UI RENDERING ----
|
||
|
||
/**
|
||
* Calculator-Body rendern: Tab-Bar + aktiver Modus
|
||
* @param {HTMLElement} bodyEl
|
||
*/
|
||
renderBody(bodyEl) {
|
||
bodyEl.textContent = '';
|
||
bodyEl.style.padding = '0';
|
||
bodyEl.style.display = 'flex';
|
||
bodyEl.style.flexDirection = 'column';
|
||
bodyEl.style.height = '100%';
|
||
|
||
// Tab-Bar
|
||
const tabBar = document.createElement('div');
|
||
tabBar.className = 'calc-tab-bar';
|
||
this._tabBarEl = tabBar;
|
||
this._renderTabBar();
|
||
|
||
// Mode-Body Container
|
||
const modeBody = document.createElement('div');
|
||
modeBody.className = 'calc-mode-body';
|
||
|
||
bodyEl.append(tabBar, modeBody);
|
||
|
||
// Aktiven Modus rendern
|
||
const mode = this._modes.get(this._activeMode);
|
||
if (mode) {
|
||
mode.render(modeBody);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Tab-Bar mit Buttons aus _modes Map befüllen
|
||
*/
|
||
_renderTabBar() {
|
||
if (!this._tabBarEl) return;
|
||
while (this._tabBarEl.firstChild) {
|
||
this._tabBarEl.removeChild(this._tabBarEl.firstChild);
|
||
}
|
||
|
||
this._modes.forEach((config, name) => {
|
||
const tab = document.createElement('button');
|
||
tab.type = 'button';
|
||
tab.className = 'calc-tab' + (name === this._activeMode ? ' active' : '');
|
||
tab.dataset.mode = name;
|
||
|
||
const icon = document.createElement('span');
|
||
icon.className = 'calc-tab-icon';
|
||
icon.textContent = config.label;
|
||
|
||
const label = document.createElement('span');
|
||
label.className = 'calc-tab-label';
|
||
label.textContent = config.shortName;
|
||
|
||
tab.append(icon, label);
|
||
tab.addEventListener('click', () => this.switchMode(name));
|
||
this._tabBarEl.appendChild(tab);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Aktiven Tab visuell markieren (ohne Neuaufbau)
|
||
*/
|
||
_updateTabBar() {
|
||
if (!this._tabBarEl) return;
|
||
const tabs = this._tabBarEl.querySelectorAll('.calc-tab');
|
||
tabs.forEach(tab => {
|
||
tab.classList.toggle('active', tab.dataset.mode === this._activeMode);
|
||
});
|
||
},
|
||
|
||
/**
|
||
* Modus wechseln
|
||
* @param {string} name - Ziel-Modus
|
||
*/
|
||
async switchMode(name) {
|
||
if (name === this._activeMode) return;
|
||
const mode = this._modes.get(name);
|
||
if (!mode) return;
|
||
|
||
// Alten Modus aufräumen
|
||
const oldMode = this._modes.get(this._activeMode);
|
||
if (oldMode && oldMode.destroy) oldMode.destroy();
|
||
|
||
this._activeMode = name;
|
||
|
||
// Mode-Body leeren und neu rendern
|
||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||
if (!entry) return;
|
||
const modeBody = entry.el.querySelector('.calc-mode-body');
|
||
if (!modeBody) return;
|
||
modeBody.textContent = '';
|
||
mode.render(modeBody);
|
||
|
||
// Tab-UI aktualisieren
|
||
this._updateTabBar();
|
||
|
||
// Auto-Resize für komplexe Modi
|
||
const isComplex = name !== 'standard';
|
||
if (isComplex && entry) {
|
||
const state = entry.state;
|
||
if (state) {
|
||
const newW = Math.max(state.width, 320);
|
||
const newH = Math.max(state.height, 480);
|
||
if (newW !== state.width || newH !== state.height) {
|
||
entry.el.style.width = newW + 'px';
|
||
entry.el.style.height = newH + 'px';
|
||
state.width = newW;
|
||
state.height = newH;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Keyboard neu binden
|
||
this._unbindKeyboard();
|
||
if (name === 'standard' || name === 'scientific') {
|
||
if (entry) this._bindKeyboard(entry.el);
|
||
}
|
||
|
||
await this.save();
|
||
},
|
||
|
||
/**
|
||
* Standard-Modus UI rendern
|
||
* @param {HTMLElement} bodyEl
|
||
*/
|
||
_renderStandardMode(bodyEl) {
|
||
bodyEl.style.padding = '8px';
|
||
bodyEl.style.display = 'flex';
|
||
bodyEl.style.flexDirection = 'column';
|
||
bodyEl.style.flex = '1';
|
||
bodyEl.style.overflow = 'hidden';
|
||
|
||
// Display
|
||
const display = document.createElement('div');
|
||
display.className = 'calc-display';
|
||
|
||
const exprEl = document.createElement('div');
|
||
exprEl.className = 'calc-expression';
|
||
this._displayExprEl = exprEl;
|
||
|
||
const resultEl = document.createElement('div');
|
||
resultEl.className = 'calc-result';
|
||
resultEl.textContent = '0';
|
||
this._displayResultEl = resultEl;
|
||
|
||
display.append(exprEl, resultEl);
|
||
|
||
// Buttons
|
||
const buttonsEl = this._createButtons();
|
||
|
||
// History
|
||
const historyEl = this._createHistoryPanel();
|
||
|
||
bodyEl.append(display, buttonsEl, historyEl);
|
||
|
||
// Aktuellen State anzeigen
|
||
this._updateDisplay();
|
||
},
|
||
|
||
/**
|
||
* Button-Grid erstellen (4x5)
|
||
* @returns {HTMLElement}
|
||
*/
|
||
_createButtons() {
|
||
const grid = document.createElement('div');
|
||
grid.className = 'calc-buttons';
|
||
|
||
// Button-Layout: [label, value, cssClass]
|
||
const buttons = [
|
||
['C', 'clear', 'clear'],
|
||
['()', 'paren', 'operator'],
|
||
['%', '%', 'operator'],
|
||
['\u00F7', '/', 'operator'],
|
||
['7', '7', ''],
|
||
['8', '8', ''],
|
||
['9', '9', ''],
|
||
['\u00D7', '*', 'operator'],
|
||
['4', '4', ''],
|
||
['5', '5', ''],
|
||
['6', '6', ''],
|
||
['\u2212', '-', 'operator'],
|
||
['1', '1', ''],
|
||
['2', '2', ''],
|
||
['3', '3', ''],
|
||
['+', '+', 'operator'],
|
||
['0', '0', ''],
|
||
['.', '.', ''],
|
||
['\u232B', 'backspace', ''],
|
||
['=', '=', 'equals']
|
||
];
|
||
|
||
buttons.forEach(([label, value, cls]) => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
|
||
btn.textContent = label;
|
||
btn.type = 'button';
|
||
btn.addEventListener('click', () => this._handleKey(value));
|
||
grid.appendChild(btn);
|
||
});
|
||
|
||
return grid;
|
||
},
|
||
|
||
/**
|
||
* History-Panel erstellen
|
||
* @returns {HTMLElement}
|
||
*/
|
||
_createHistoryPanel() {
|
||
const container = document.createElement('div');
|
||
container.className = 'calc-history';
|
||
container.id = 'calcHistoryPanel';
|
||
|
||
const title = document.createElement('div');
|
||
title.className = 'calc-history-title';
|
||
title.textContent = t('calculator.history');
|
||
container.appendChild(title);
|
||
|
||
this._renderHistoryItems(container);
|
||
|
||
return container;
|
||
},
|
||
|
||
/**
|
||
* History-Items rendern
|
||
* @param {HTMLElement} container
|
||
*/
|
||
_renderHistoryItems(container) {
|
||
// Alte Items entfernen (nur die .calc-history-item Elemente)
|
||
const oldItems = container.querySelectorAll('.calc-history-item');
|
||
oldItems.forEach(item => item.remove());
|
||
|
||
if (this._history.length === 0) return;
|
||
|
||
// Neueste zuerst
|
||
const reversed = [...this._history].reverse();
|
||
reversed.forEach(entry => {
|
||
const item = document.createElement('div');
|
||
item.className = 'calc-history-item';
|
||
|
||
const exprSpan = document.createElement('span');
|
||
exprSpan.textContent = entry.expr;
|
||
|
||
const resultSpan = document.createElement('span');
|
||
resultSpan.className = 'calc-h-result';
|
||
resultSpan.textContent = '= ' + entry.result;
|
||
|
||
item.append(exprSpan, resultSpan);
|
||
|
||
// Klick uebernimmt Ergebnis als neue Eingabe
|
||
item.addEventListener('click', () => {
|
||
this._currentExpr = entry.result;
|
||
this._lastResult = '';
|
||
this._updateDisplay();
|
||
});
|
||
|
||
container.appendChild(item);
|
||
});
|
||
},
|
||
|
||
// ---- INPUT HANDLING ----
|
||
|
||
/**
|
||
* Taste verarbeiten
|
||
* @param {string} key
|
||
*/
|
||
_handleKey(key) {
|
||
switch (key) {
|
||
case 'clear':
|
||
this._currentExpr = '';
|
||
this._lastResult = '';
|
||
break;
|
||
|
||
case 'backspace':
|
||
this._currentExpr = this._currentExpr.slice(0, -1);
|
||
break;
|
||
|
||
case '=':
|
||
this._calculate();
|
||
return;
|
||
|
||
case 'paren': {
|
||
// Smarte Klammern: oeffnende wenn noetig, sonst schliessende
|
||
const openCount = (this._currentExpr.match(/\(/g) || []).length;
|
||
const closeCount = (this._currentExpr.match(/\)/g) || []).length;
|
||
const lastChar = this._currentExpr.slice(-1);
|
||
if (openCount <= closeCount || /[+\-*/%(]$/.test(lastChar) || this._currentExpr === '') {
|
||
this._currentExpr += '(';
|
||
} else {
|
||
this._currentExpr += ')';
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '%':
|
||
case '+':
|
||
case '-':
|
||
case '*':
|
||
case '/':
|
||
case '^': {
|
||
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
|
||
if (this._lastResult && this._currentExpr === '') {
|
||
this._currentExpr = this._lastResult;
|
||
this._lastResult = '';
|
||
}
|
||
// Doppelte Operatoren verhindern (letzten ersetzen)
|
||
const last = this._currentExpr.slice(-1);
|
||
if (/[+\-*/%^]/.test(last)) {
|
||
this._currentExpr = this._currentExpr.slice(0, -1) + key;
|
||
} else {
|
||
this._currentExpr += key;
|
||
}
|
||
break;
|
||
}
|
||
|
||
case '.': {
|
||
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
|
||
const parts = this._currentExpr.split(/[+\-*/%()^]/);
|
||
const lastPart = parts[parts.length - 1];
|
||
if (lastPart && lastPart.includes('.')) break;
|
||
this._currentExpr += key;
|
||
break;
|
||
}
|
||
|
||
default:
|
||
// Ziffern 0-9
|
||
if (/^[0-9]$/.test(key)) {
|
||
// Wenn ein Ergebnis da ist und User eine Zahl tippt, neue Berechnung starten
|
||
if (this._lastResult && this._currentExpr === '') {
|
||
this._lastResult = '';
|
||
}
|
||
this._currentExpr += key;
|
||
}
|
||
break;
|
||
}
|
||
|
||
this._updateDisplay();
|
||
},
|
||
|
||
/**
|
||
* Berechnung ausfuehren
|
||
*/
|
||
async _calculate() {
|
||
if (!this._currentExpr) return;
|
||
|
||
const result = this._evaluate(this._currentExpr);
|
||
if (result === null) {
|
||
this._lastResult = t('calculator.error');
|
||
this._updateDisplay();
|
||
return;
|
||
}
|
||
|
||
const resultStr = this._formatResult(result);
|
||
this._addHistory(this._currentExpr, resultStr);
|
||
this._lastResult = resultStr;
|
||
|
||
// Display aktualisieren
|
||
if (this._displayExprEl) {
|
||
this._displayExprEl.textContent = this._formatExpression(this._currentExpr) + ' =';
|
||
}
|
||
if (this._displayResultEl) {
|
||
this._displayResultEl.textContent = resultStr;
|
||
}
|
||
|
||
this._currentExpr = '';
|
||
|
||
// History-Panel aktualisieren
|
||
const historyPanel = document.getElementById('calcHistoryPanel');
|
||
if (historyPanel) this._renderHistoryItems(historyPanel);
|
||
|
||
await this.save();
|
||
},
|
||
|
||
// ---- EXPRESSION PARSER (Shunting-Yard, KEIN eval!) ----
|
||
|
||
/**
|
||
* Expression sicher auswerten
|
||
* @param {string} expr
|
||
* @returns {number|null}
|
||
*/
|
||
_evaluate(expr) {
|
||
try {
|
||
// Nur erlaubte Zeichen
|
||
const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
|
||
if (!sanitized) return null;
|
||
|
||
const tokens = this._tokenize(sanitized);
|
||
if (!tokens) return null;
|
||
|
||
return this._parseExpression(tokens);
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
|
||
/**
|
||
* Expression in Tokens aufteilen
|
||
* @param {string} expr
|
||
* @returns {Array|null}
|
||
*/
|
||
_tokenize(expr) {
|
||
const tokens = [];
|
||
let i = 0;
|
||
|
||
while (i < expr.length) {
|
||
const ch = expr[i];
|
||
|
||
// Funktion: sqrt
|
||
if (expr.substring(i, i + 4) === 'sqrt') {
|
||
tokens.push({ type: 'func', value: 'sqrt' });
|
||
i += 4;
|
||
continue;
|
||
}
|
||
|
||
// Zahl (inkl. Dezimal)
|
||
if (/[0-9.]/.test(ch)) {
|
||
let num = '';
|
||
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
||
num += expr[i];
|
||
i++;
|
||
}
|
||
const parsed = parseFloat(num);
|
||
if (isNaN(parsed)) return null;
|
||
tokens.push({ type: 'number', value: parsed });
|
||
continue;
|
||
}
|
||
|
||
// Operator
|
||
if (/[+\-*/%]/.test(ch)) {
|
||
// Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
|
||
if (ch === '-') {
|
||
const prev = tokens[tokens.length - 1];
|
||
if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
|
||
// Negatives Vorzeichen → als Teil der naechsten Zahl lesen
|
||
let num = '-';
|
||
i++;
|
||
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
||
num += expr[i];
|
||
i++;
|
||
}
|
||
if (num === '-') return null;
|
||
const parsed = parseFloat(num);
|
||
if (isNaN(parsed)) return null;
|
||
tokens.push({ type: 'number', value: parsed });
|
||
continue;
|
||
}
|
||
}
|
||
tokens.push({ type: 'op', value: ch });
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// Potenz-Operator
|
||
if (ch === '^') {
|
||
tokens.push({ type: 'op', value: '^' });
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// Klammern
|
||
if (ch === '(' || ch === ')') {
|
||
tokens.push({ type: 'paren', value: ch });
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
// Unbekannte Buchstaben
|
||
if (/[a-z]/.test(ch)) {
|
||
return null;
|
||
}
|
||
|
||
// Unbekanntes Zeichen
|
||
return null;
|
||
}
|
||
|
||
return tokens;
|
||
},
|
||
|
||
/**
|
||
* Rekursiver Descent Parser mit Operator-Precedence
|
||
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
|
||
* @param {Array} tokens
|
||
* @returns {number|null}
|
||
*/
|
||
_parseExpression(tokens) {
|
||
let pos = 0;
|
||
|
||
function peek() { return tokens[pos]; }
|
||
function consume() { return tokens[pos++]; }
|
||
|
||
function parseExpr() {
|
||
let left = parseTerm();
|
||
if (left === null) return null;
|
||
while (pos < tokens.length) {
|
||
const tk = peek();
|
||
if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
|
||
consume();
|
||
const right = parseTerm();
|
||
if (right === null) return null;
|
||
left = tk.value === '+' ? left + right : left - right;
|
||
}
|
||
return left;
|
||
}
|
||
|
||
function parseTerm() {
|
||
let left = parsePower();
|
||
if (left === null) return null;
|
||
while (pos < tokens.length) {
|
||
const tk = peek();
|
||
if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
|
||
consume();
|
||
const right = parsePower();
|
||
if (right === null) return null;
|
||
if (tk.value === '*') {
|
||
left = left * right;
|
||
} else if (tk.value === '/') {
|
||
if (right === 0) return null;
|
||
left = left / right;
|
||
} else {
|
||
left = left % right;
|
||
}
|
||
}
|
||
return left;
|
||
}
|
||
|
||
// Power: Factor ('^' Power)? — rechts-assoziativ via Rekursion
|
||
function parsePower() {
|
||
let base = parseFactor();
|
||
if (base === null) return null;
|
||
const tk = peek();
|
||
if (tk && tk.type === 'op' && tk.value === '^') {
|
||
consume();
|
||
const exp = parsePower(); // Rechts-assoziativ!
|
||
if (exp === null) return null;
|
||
return Math.pow(base, exp);
|
||
}
|
||
return base;
|
||
}
|
||
|
||
// Factor: func '(' Expression ')' | Number | '(' Expression ')'
|
||
function parseFactor() {
|
||
const tk = peek();
|
||
if (!tk) return null;
|
||
|
||
// Funktion: sqrt(...)
|
||
if (tk.type === 'func') {
|
||
const funcName = tk.value;
|
||
consume();
|
||
const open = peek();
|
||
if (!open || open.type !== 'paren' || open.value !== '(') return null;
|
||
consume();
|
||
const val = parseExpr();
|
||
if (val === null) return null;
|
||
const close = peek();
|
||
if (close && close.type === 'paren' && close.value === ')') {
|
||
consume();
|
||
}
|
||
if (funcName === 'sqrt') {
|
||
if (val < 0) return null; // Negativer Radikand nicht erlaubt
|
||
return Math.sqrt(val);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
if (tk.type === 'number') {
|
||
consume();
|
||
return tk.value;
|
||
}
|
||
|
||
if (tk.type === 'paren' && tk.value === '(') {
|
||
consume();
|
||
const val = parseExpr();
|
||
if (val === null) return null;
|
||
const closing = peek();
|
||
if (closing && closing.type === 'paren' && closing.value === ')') {
|
||
consume();
|
||
}
|
||
return val;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const result = parseExpr();
|
||
|
||
// Alle Tokens muessen verbraucht sein
|
||
if (pos < tokens.length) return null;
|
||
|
||
if (result === null || !isFinite(result)) return null;
|
||
return result;
|
||
},
|
||
|
||
// ---- FORMATTING ----
|
||
|
||
/**
|
||
* Ergebnis formatieren (maximal 10 Dezimalstellen, trailing Nullen entfernen)
|
||
* @param {number} num
|
||
* @returns {string}
|
||
*/
|
||
_formatResult(num) {
|
||
if (Number.isInteger(num)) return num.toString();
|
||
// Maximal 10 Dezimalstellen, trailing Nullen weg
|
||
const str = num.toFixed(10).replace(/\.?0+$/, '');
|
||
return str;
|
||
},
|
||
|
||
/**
|
||
* Expression fuer Anzeige formatieren (× statt *, ÷ statt /)
|
||
* @param {string} expr
|
||
* @returns {string}
|
||
*/
|
||
_formatExpression(expr) {
|
||
return expr
|
||
.replace(/\*/g, '\u00D7')
|
||
.replace(/\//g, '\u00F7')
|
||
.replace(/sqrt\(/g, '\u221A(');
|
||
},
|
||
|
||
// ---- DISPLAY ----
|
||
|
||
/**
|
||
* Display aktualisieren
|
||
*/
|
||
_updateDisplay() {
|
||
if (this._displayExprEl) {
|
||
if (this._lastResult) {
|
||
// Ergebnis-Modus: Expression oben, Ergebnis gross
|
||
// (wird von _calculate() direkt gesetzt)
|
||
} else {
|
||
this._displayExprEl.textContent = '';
|
||
}
|
||
}
|
||
if (this._displayResultEl) {
|
||
if (this._lastResult && this._currentExpr === '') {
|
||
this._displayResultEl.textContent = this._lastResult;
|
||
} else {
|
||
this._displayResultEl.textContent = this._formatExpression(this._currentExpr) || '0';
|
||
}
|
||
}
|
||
},
|
||
|
||
// ---- HISTORY ----
|
||
|
||
/**
|
||
* History-Eintrag hinzufuegen
|
||
* @param {string} expr
|
||
* @param {string} result
|
||
*/
|
||
_addHistory(expr, result) {
|
||
this._history.push({
|
||
expr: this._formatExpression(expr),
|
||
result: result
|
||
});
|
||
// Limit einhalten
|
||
if (this._history.length > this.MAX_HISTORY) {
|
||
this._history = this._history.slice(-this.MAX_HISTORY);
|
||
}
|
||
},
|
||
|
||
// ---- KEYBOARD ----
|
||
|
||
/**
|
||
* Tastatur-Events binden
|
||
* @param {HTMLElement} widgetEl
|
||
*/
|
||
_bindKeyboard(widgetEl) {
|
||
this._unbindKeyboard();
|
||
|
||
this._keydownHandler = (e) => {
|
||
// Nur reagieren wenn Calculator-Widget fokussiert ist
|
||
// (d.h. nicht wenn User in Textarea/Input tippt)
|
||
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
|
||
if (e.target.contentEditable === 'true') return;
|
||
|
||
const key = e.key;
|
||
let handled = false;
|
||
|
||
if (/^[0-9]$/.test(key)) {
|
||
this._handleKey(key);
|
||
handled = true;
|
||
} else if (key === '+' || key === '-' || key === '*' || key === '/') {
|
||
this._handleKey(key);
|
||
handled = true;
|
||
} else if (key === '.') {
|
||
this._handleKey('.');
|
||
handled = true;
|
||
} else if (key === '%') {
|
||
this._handleKey('%');
|
||
handled = true;
|
||
} else if (key === '(' || key === ')') {
|
||
this._handleKey('paren');
|
||
handled = true;
|
||
} else if (key === 'Enter' || key === '=') {
|
||
this._handleKey('=');
|
||
handled = true;
|
||
} else if (key === 'Backspace') {
|
||
this._handleKey('backspace');
|
||
handled = true;
|
||
} else if (key === 'Escape' || key === 'c' || key === 'C') {
|
||
this._handleKey('clear');
|
||
handled = true;
|
||
}
|
||
|
||
if (handled) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
}
|
||
};
|
||
|
||
widgetEl.addEventListener('keydown', this._keydownHandler);
|
||
// Widget fokussierbar machen
|
||
widgetEl.tabIndex = 0;
|
||
widgetEl.focus();
|
||
},
|
||
|
||
/**
|
||
* Keyboard-Events entfernen
|
||
*/
|
||
_unbindKeyboard() {
|
||
if (this._keydownHandler) {
|
||
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||
if (entry) {
|
||
entry.el.removeEventListener('keydown', this._keydownHandler);
|
||
}
|
||
this._keydownHandler = null;
|
||
}
|
||
},
|
||
|
||
// ---- INIT ----
|
||
|
||
/**
|
||
* Calculator initialisieren (aus app.js aufgerufen)
|
||
*/
|
||
async init() {
|
||
await this.load();
|
||
|
||
// Standard-Modus ZUERST registrieren, bevor open() aufgerufen wird
|
||
this._modes.set('standard', {
|
||
label: '🔢',
|
||
shortName: 'Std',
|
||
titleKey: 'calculator.tab.standard',
|
||
render: (bodyEl) => this._renderStandardMode(bodyEl),
|
||
destroy: () => {
|
||
this._displayExprEl = null;
|
||
this._displayResultEl = null;
|
||
}
|
||
});
|
||
|
||
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
|
||
const data = await Store.get(this.STORAGE_KEY);
|
||
if (data && data.calculator && data.calculator.open) {
|
||
await this.open();
|
||
}
|
||
|
||
// Widget-Lifecycle-Events
|
||
const self = this;
|
||
WidgetManager.on('widget:close', (e) => {
|
||
if (e.detail.id === self.WIDGET_ID) {
|
||
self.onClose();
|
||
}
|
||
});
|
||
|
||
WidgetManager.on('widget:minimize', (e) => {
|
||
if (e.detail.id === self.WIDGET_ID) {
|
||
self._isOpen = false;
|
||
self.save();
|
||
}
|
||
});
|
||
|
||
WidgetManager.on('widget:open', (e) => {
|
||
if (e.detail.id === self.WIDGET_ID) {
|
||
self._isOpen = true;
|
||
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||
if (body && body.children.length === 0) {
|
||
self.renderBody(body);
|
||
}
|
||
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||
if (entry) self._bindKeyboard(entry.el);
|
||
self.save();
|
||
}
|
||
});
|
||
}
|
||
};
|