32 Commits

Author SHA1 Message Date
JonKazama-Hellion 60a1bec00d feat(release): Version auf v2.1.0 bumpen — Calculator Upgrade
Alle 3 Manifests, newtab.html, data.js, app.js auf 2.1.0.
CHANGELOG-Eintrag mit allen 6 neuen Calculator-Modi.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:21:45 +02:00
JonKazama-Hellion 153db9c24d feat(calculator): Stationeers Calculator mit Gas/Furnace/Solar/Atmo
4 Sub-Modi: PV=nRT Gas-Rechner, Furnace Combustion,
Solar/Battery Planung und Atmosphere Mixer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:20:09 +02:00
JonKazama-Hellion 2e691b8b51 fix(calculator): save() überschreibt Game-Mode Sub-States nicht mehr
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>
2026-04-16 22:16:19 +02:00
JonKazama-Hellion 11419bd589 feat(calculator): Factorio Calculator mit Ratio, Belt und Maschinen-Modus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:12:28 +02:00
JonKazama-Hellion 27fa4f53af feat(calculator): Satisfactory Calculator mit Overclock-Power 2026-04-16 22:10:11 +02:00
JonKazama-Hellion 8fdd46beec feat(calculator): Unit-Converter mit 6 Kategorien 2026-04-16 22:05:51 +02:00
JonKazama-Hellion 3dd9723271 fix(calc-scientific): sqrt/square mit lastResult, Negate-Regex
sqrt und x² transferieren _lastResult in _currentExpr wenn Expression leer.
handleNegate Regex akzeptiert auch dezimal-first Zahlen (.5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 22:03:02 +02:00
JonKazama-Hellion 2f23c13de1 feat(calculator): Scientific-Modus implementieren
Neuer Taschenrechner-Modus mit 6 wissenschaftlichen Buttons (√, x², xⁿ, π, e, ±),
Formel-Helfer (Kreisfläche, Kreisumfang, °C↔°F, Pythagoras, Prozent) und
erweiterter Tastaturunterstützung (p → Pi, ^ → Potenz).

- calc-scientific.js: IIFE, registriert 'scientific' Mode via Calculator.registerMode()
- newtab.html: Script-Tag nach calculator.js eingefügt (Load-Order eingehalten)
- i18n.js: 15 neue Keys pro Sprache (DE + EN)
- main.css: Styles für .calc-sci-buttons und .calc-formula-* Komponenten
2026-04-16 21:59:10 +02:00
JonKazama-Hellion f5cebd8d34 fix(calculator): Dezimal-Eingabe nach ^ ermöglichen
Split-Regex für Dezimalpunkt-Check um ^ erweitern,
damit z.B. 2.3^1.5 korrekt eingegeben werden kann.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:56:29 +02:00
JonKazama-Hellion 10318008e6 feat(calculator): Parser um ^ (Potenz) und sqrt() erweitern 2026-04-16 21:51:30 +02:00
JonKazama-Hellion 50319f8ba9 fix(calculator): init-Reihenfolge und save() Daten-Erhalt
Standard-Modus vor open() registrieren (verhindert leeres Widget bei Restore).
save() nutzt read-modify-write statt Overwrite (bewahrt Timer/ImageRef-Daten).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:49:51 +02:00
JonKazama-Hellion b71e8cde1b fix(calculator): Auto-Resize via DOM statt nicht-existierendem WidgetManager.resize()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:45:35 +02:00
JonKazama-Hellion 2487ac772f feat(calculator): Tab-System mit registerMode() und switchMode()
- Neue Eigenschaften: _modes (Map), _activeMode, _tabBarEl
- registerMode() für externe Mode-Dateien, aktualisiert Tab-Bar live
- renderBody() baut jetzt Tab-Bar + .calc-mode-body Container
- _renderTabBar(), _updateTabBar(), switchMode() implementiert
- _renderStandardMode() extrahiert aus altem renderBody()
- save()/load(): activeMode wird persistiert und wiederhergestellt
- onClose(): aktiven Modus sauber aufräumen, _tabBarEl zurücksetzen
- CSS: .calc-tab-bar, .calc-tab, .calc-tab-icon, .calc-tab-label, .calc-mode-body
- i18n: calculator.tab.standard (DE + EN)
2026-04-16 21:43:03 +02:00
JonKazama-Hellion 7be391de99 docs(plan): Calculator Upgrade v2.1.0 Implementierungsplan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:38:57 +02:00
JonKazama-Hellion cebf277a5d docs(spec): Calculator Upgrade v2.1.0 Design-Spec
6 Modi: Standard, Scientific, Unit-Converter, Satisfactory, Factorio, Stationeers.
Tab-basierte Architektur, Registrierungs-Pattern, erweiteter Shunting-Yard Parser.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 21:22:07 +02:00
JonKazama-Hellion 92c5b23b44 fix: image-ref Titel-Bug, CSP, ARIA-Labels, Onboarding i18n
- image-ref.js: .widget-title-text → .widget-title (Titel-Update war kaputt)
- Alle 3 Manifests: explizites content_security_policy (script-src/object-src 'self')
- i18n.js: applyLanguage() setzt aria-label synchron zu title
- newtab.html: 6 Close-Buttons + Search-Submit mit data-i18n-title
- i18n.js: 4 neue Keys (dialog.close, search.submit_title, onboarding.tradecenter_desc)
- onboarding.js: TradeCenter-Beschreibung auf t() umgestellt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:51:54 +02:00
JonKazama-Hellion 7f22627272 Merge branch 'feature/hardening-v2.0.1' — Hardening Release v2.0.1
Security: URL-Validierung (bgUrl + Import), immutable Data-Mapping
Stability: Widget Event-System (EventTarget), transitionend Race Fix
Privacy: Lokale Letter-Icons statt Google Favicons API
Compat: backdrop-filter Fallback, _locales in Release-ZIPs
i18n: 10 fehlende Übersetzungs-Keys ergänzt
Quality: Clock Interval ID, Notes-Import über init()
2026-04-16 20:37:03 +02:00
JonKazama-Hellion 9b6515aab3 fix(ci): _locales/ in alle Release-ZIPs aufnehmen
Manifest nutzt __MSG_extName__ / __MSG_extDesc__ mit default_locale,
aber die _locales/ Dateien fehlten in allen drei ZIP-Befehlen.
Ohne sie kann der Browser die Extension nicht laden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:35:59 +02:00
JonKazama-Hellion 675e21d886 fix(release): fehlende Versionsnummern + bgUrl-Validierung
Drei Stellen hatten noch '2.0.0' statt '2.0.1': newtab.html About-Sektion,
data.js Export und app.js Backup-Export. FileReader-Upload in settings.js
validiert jetzt bgUrl via isValidBgUrl() (Defense-in-Depth).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:33:38 +02:00
JonKazama-Hellion 536e0771a4 chore(release): bump version to v2.0.1 — hardening release
Security fixes, widget event system, local favicons, i18n completeness,
backdrop-filter fallback, code quality improvements. See CHANGELOG.md.
2026-04-16 20:28:22 +02:00
JonKazama-Hellion 02cdee76a8 fix(i18n): complete missing translations for toolbar tooltips and button texts
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
validation error messages.
2026-04-16 20:27:17 +02:00
JonKazama-Hellion b6d347cd15 fix(compat): add backdrop-filter fallback for Brave Shields
Add --bg-solid-fallback CSS variable to all 11 themes and a
@supports not (backdrop-filter) block. UI remains usable when
Brave Shields or strict fingerprinting settings block backdrop-filter.
2026-04-16 20:25:10 +02:00
JonKazama-Hellion 6704f4c955 feat(privacy): replace Google Favicons with local letter icons
Remove getFaviconUrl() and all external network requests. Bookmarks now
show a colored letter icon with deterministic hue based on title.
Eliminates privacy leak and Brave Shields compatibility issues.
2026-04-16 20:22:18 +02:00
JonKazama-Hellion a3e21a760f fix(security): harden JSON import with URL validation and immutable mapping
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
Replace mutable object mutation with immutable .map() and string length limits.
Use Notes.init()/Calculator.load()/Timer.load() instead of direct internal
mutation after import.
2026-04-16 20:20:38 +02:00
JonKazama-Hellion 82dd6e026a fix(security): validate background URL before CSS injection
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
Applied in applySettings() and the manual URL input handler.
Prevents CSS injection via manipulated bgUrl storage values.
2026-04-16 20:18:42 +02:00
JonKazama-Hellion 2430d65e3a refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
WidgetManager.on() event listeners. Eliminates 3-deep closure chain.
2026-04-16 20:15:08 +02:00
JonKazama-Hellion 30df93a4cc fix(widgets): harden minimize transitionend with fallback and property filter
- Filter transitionend by propertyName=opacity to prevent double-fire
- Add 350ms fallback setTimeout for prefers-reduced-motion / zero-duration
- Initialize _minimizing: false in create() for clean state
- Dispatch events after save() for consistent state in listeners

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:13:47 +02:00
JonKazama-Hellion b92ea5a1a4 fix(widgets): replace setTimeout with transitionend in minimize
Fixes race condition where openWidget() during the 250ms timeout would
be overridden. Uses _minimizing flag to cancel in-flight transitions.
Dispatches widget:minimize and widget:open events.
2026-04-16 20:10:30 +02:00
JonKazama-Hellion fde1fdd002 fix(widgets): dispatch close event before registry cleanup
Move widget:close dispatch before _widgets.delete() so handlers can
still query WidgetManager for the widget's state during the event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:09:43 +02:00
JonKazama-Hellion 7cda3019c8 refactor(widgets): add EventTarget-based lifecycle event system
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
after close(). Foundation for removing monkey-patching from widget modules.
2026-04-16 20:07:04 +02:00
JonKazama-Hellion 3de1dd3b8b docs(plan): Hardening v2.0.1 Implementierungsplan erstellen
9 Tasks, 16 Dateien, Foundation-First-Strategie.
Event-System → Widget-Migration → Security → Favicons → i18n → Quality → Release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:29:14 +02:00
JonKazama-Hellion 63825cd393 docs(spec): Hardening v2.0.1 Design-Dokument erstellen
Audit-basierte Spec für Security, Stability, i18n und Code-Quality Fixes.
Foundation-First-Strategie: Event-System → Widget-Migration → Security → i18n → Quality.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:25:03 +02:00
28 changed files with 7497 additions and 186 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ jobs:
run: | run: |
mkdir -p dist mkdir -p dist
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \ zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ \ manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "src/js/opera/*" -x "*.git*" "dist/*" ".github/*" "src/js/opera/*"
- name: Create Firefox ZIP (Manifest V3) - name: Create Firefox ZIP (Manifest V3)
@@ -33,7 +33,7 @@ jobs:
cp manifest.json manifest.chrome-backup.json cp manifest.json manifest.chrome-backup.json
cp manifest.firefox.json manifest.json cp manifest.firefox.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \ zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \
manifest.json newtab.html src/js/*.js src/css/ assets/ \ manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*" -x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*"
mv manifest.chrome-backup.json manifest.json mv manifest.chrome-backup.json manifest.json
@@ -42,7 +42,7 @@ jobs:
cp manifest.json manifest.chrome-backup.json cp manifest.json manifest.chrome-backup.json
cp manifest.opera.json manifest.json cp manifest.opera.json manifest.json
zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \ zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \
manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ \ manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \
-x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json" -x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json"
mv manifest.chrome-backup.json manifest.json mv manifest.chrome-backup.json manifest.json
+3
View File
@@ -24,3 +24,6 @@ updates.json
# Persönliche Backup-Dateien (nicht ins Repo) # Persönliche Backup-Dateien (nicht ins Repo)
favorites_*.html favorites_*.html
*_backup*.json *_backup*.json
.mcp.json
.claude
.superpowers/
+40
View File
@@ -6,6 +6,46 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep
--- ---
## [2.1.0] — 2026-04-16
### Added
- **Calculator Tab-System:** 6 Modi über Tab-Leiste erreichbar (Standard, Scientific, Unit, SAT, FAC, STA)
- **Scientific-Modus:** Wurzel, Potenz, Pi, Euler, Vorzeichen-Wechsel + Formel-Helfer (Kreis, Pythagoras, Prozent, Temperatur)
- **Unit-Converter:** 6 Kategorien (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche) mit Live-Konvertierung und Swap
- **Satisfactory Calculator:** Items/Min, Overclock-Power (Exponent 1.321928), Maschinen-Rechner
- **Factorio Calculator:** Assembler-Ratios, Belt-Throughput, Maschinen-Rechner mit Belt-Empfehlung
- **Stationeers Calculator:** Idealgas (PV=nRT), Furnace/Verbrennung, Solar/Batterie-Dimensionierung, Atmosphären-Mixer
### Changed
- Parser um `^` (Potenz, rechts-assoziativ) und `sqrt()` erweitert
- Calculator-Widget Auto-Resize auf 320×480 für komplexe Modi
- ~110 neue i18n-Keys (DE + EN)
---
### v2.0.1 — 16.04.2026
#### Security
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
#### Fixed
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
#### Changed
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
- **Clock interval cleanup** — `setInterval` ID stored in variable
---
### v2.0.0 — 22.03.2026 ### v2.0.0 — 22.03.2026
#### New Features #### New Features
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,955 @@
# Hellion NewTab v2.0.1 Hardening — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Harden v2.0.0 with security fixes, widget event-system refactoring, i18n completeness, and code quality improvements.
**Architecture:** Foundation-First — build the new widget event system first, then migrate widget modules onto it, then layer security, i18n, and quality fixes. Each task touches isolated files to avoid merge conflicts.
**Tech Stack:** Vanilla JavaScript ES2020, CSS Custom Properties, Browser Extension Manifest V3, no build step, no npm.
**Spec:** `docs/superpowers/specs/2026-04-16-hardening-v2.0.1-design.md`
**Testing:** No automated test framework. Each task includes manual browser-based verification steps. Load the extension in Chrome (`chrome://extensions` → Developer mode → Load unpacked) after each task.
---
## File Map
| File | Tasks | Changes |
|---|---|---|
| `src/js/widgets.js` | 1, 2 | Add event system (`_emitter`, `on`, `off`), dispatch events in `close`/`minimize`/`openWidget`, replace `setTimeout` with `transitionend` |
| `src/js/calculator.js` | 3 | Replace monkey-patching (L692-728) with `WidgetManager.on()` listeners |
| `src/js/timer.js` | 3 | Replace monkey-patching (L723-758) with `WidgetManager.on()` listeners |
| `src/js/image-ref.js` | 3 | Replace monkey-patching (L463-498) with `WidgetManager.on()` listeners |
| `src/js/settings.js` | 4 | Add `isValidBgUrl()`, validate in `applySettings()` and file upload + URL input handlers |
| `src/js/data.js` | 5 | Add `isSafeUrl()`, immutable mapping, string length limits, Notes import via `Notes.init()` |
| `src/js/state.js` | 6 | Remove `getFaviconUrl()` |
| `src/js/boards.js` | 6 | Replace `<img>` favicon with local letter-div |
| `src/css/main.css` | 6, 7 | Replace `.bm-favicon`/`.bm-favicon-fallback` with `.bm-favicon-local`, add `@supports not` fallback, add `--bg-solid-fallback` per theme |
| `newtab.html` | 8 | Add 5x `data-i18n-title`, 3x `data-i18n` |
| `src/js/i18n.js` | 8 | Add 10 new keys to `STRINGS.de` and `STRINGS.en` (8 i18n + 2 bgUrl validation) |
| `src/js/app.js` | 9 | Store `setInterval` ID in variable |
| `manifest.json` | 9 | Version bump to 2.0.1 |
| `manifest.firefox.json` | 9 | Version bump to 2.0.1 |
| `manifest.opera.json` | 9 | Version bump to 2.0.1 |
| `CHANGELOG.md` | 9 | Add v2.0.1 entry |
---
### Task 1: Widget Event-System in WidgetManager
**Files:**
- Modify: `src/js/widgets.js:6-10` (add emitter + on/off)
- Modify: `src/js/widgets.js:143-148` (close — dispatch event)
- [ ] **Step 1: Add event emitter and on/off methods to WidgetManager**
In `src/js/widgets.js`, add three new properties after `STORAGE_KEY: 'widgetStates',` (line 10):
```javascript
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
_emitter: new EventTarget(),
/**
* Event-Listener registrieren
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
* @param {Function} handler
*/
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
/**
* Event-Listener entfernen
* @param {string} event
* @param {Function} handler
*/
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
```
- [ ] **Step 2: Dispatch `widget:close` event in close()**
Replace the `close` method (lines 143-148):
```javascript
close(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.el.remove();
this._widgets.delete(id);
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
},
```
Note: The event fires AFTER `el.remove()` and `_widgets.delete()`. Listeners must not access the widget entry.
- [ ] **Step 3: Verify event system loads without errors**
Reload the extension in the browser. Open the console (`F12`). Verify:
- No JavaScript errors on load
- `WidgetManager.on` is a function (type `WidgetManager.on` in console)
- `WidgetManager._emitter` is an EventTarget
- [ ] **Step 4: Commit**
```bash
git add src/js/widgets.js
git commit -m "refactor(widgets): add EventTarget-based lifecycle event system
Add _emitter, on(), off() to WidgetManager. Dispatch widget:close event
after close(). Foundation for removing monkey-patching from widget modules."
```
---
### Task 2: Minimize with transitionend + openWidget event dispatch
**Files:**
- Modify: `src/js/widgets.js:154-163` (minimize)
- Modify: `src/js/widgets.js:169-180` (openWidget)
- [ ] **Step 1: Replace setTimeout with transitionend in minimize()**
Replace the `minimize` method (lines 154-163):
```javascript
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized');
entry.el.addEventListener('transitionend', function onEnd(e) {
if (e.target !== entry.el) return;
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
}
entry._minimizing = false;
});
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
await this.save();
},
```
- [ ] **Step 2: Add race-condition guard and event dispatch to openWidget()**
Replace the `openWidget` method (lines 169-180):
```javascript
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry._minimizing = false;
entry.state.open = true;
entry.el.style.display = 'flex';
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
await this.save();
},
```
Key change: `entry._minimizing = false` cancels any in-flight minimize transition.
- [ ] **Step 3: Verify minimize/open animation works**
Reload extension. Test:
1. Create a note → minimize it → verify it fades out and disappears
2. Click the note in the widget toolbar to reopen → verify it appears smoothly
3. Rapid test: minimize → immediately reopen before animation ends → verify no display glitch (the race condition fix)
- [ ] **Step 4: Commit**
```bash
git add src/js/widgets.js
git commit -m "fix(widgets): replace setTimeout with transitionend in minimize
Fixes race condition where openWidget() during the 250ms timeout would
be overridden. Uses _minimizing flag to cancel in-flight transitions.
Dispatches widget:minimize and widget:open events."
```
---
### Task 3: Migrate Calculator, Timer, ImageRef to Event Listeners
**Files:**
- Modify: `src/js/calculator.js:692-728`
- Modify: `src/js/timer.js:723-758`
- Modify: `src/js/image-ref.js:463-498`
- [ ] **Step 1: Replace monkey-patching in calculator.js**
Replace lines 692-728 (the three monkey-patching blocks in `init()`) with:
```javascript
// 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();
}
});
```
- [ ] **Step 2: Replace monkey-patching in timer.js**
Replace lines 723-758 (the three monkey-patching blocks in `init()`) with:
```javascript
// 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();
}
});
```
- [ ] **Step 3: Replace monkey-patching in image-ref.js**
Replace lines 463-498 (the three monkey-patching blocks in `init()`) with:
```javascript
// Widget-Lifecycle-Events
const self = this;
WidgetManager.on('widget:close', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.onClose(e.detail.id);
}
});
WidgetManager.on('widget:minimize', (e) => {
const isImage = self._images.some(img => img.id === e.detail.id);
if (isImage) {
self.save();
}
});
WidgetManager.on('widget:open', (e) => {
const imgData = self._images.find(img => img.id === e.detail.id);
if (imgData) {
const body = WidgetManager.getBody(e.detail.id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(e.detail.id);
self.renderBody(imgData, body, dataUrl);
}
self.save();
}
});
```
- [ ] **Step 4: Verify all three widget types work**
Reload extension. Test each widget type:
1. **Calculator:** Open → type a calculation → minimize → reopen → verify history is still there → close → reopen from toolbar
2. **Timer:** Open → set a time → minimize → reopen → verify time is preserved → close
3. **Image-Ref:** Enable in Settings → open image widget → add an image → minimize → reopen → verify image displays → close
Check console for any errors during all operations.
- [ ] **Step 5: Commit**
```bash
git add src/js/calculator.js src/js/timer.js src/js/image-ref.js
git commit -m "refactor(widgets): migrate Calculator, Timer, ImageRef to event listeners
Replace monkey-patching of WidgetManager.close/minimize/openWidget with
WidgetManager.on() event listeners. Eliminates 3-deep closure chain."
```
---
### Task 4: Security — URL Validation in settings.js
**Files:**
- Modify: `src/js/settings.js:52-95` (applySettings)
- Modify: `src/js/settings.js:166-175` (btnApplyBg handler)
- Modify: `src/js/settings.js:181-194` (bgFileInput handler)
- [ ] **Step 1: Add isValidBgUrl() helper**
Add this function at the top of `settings.js`, after the `closeThemeModal()` function (after line 24):
```javascript
/**
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
* @param {string} url
* @returns {boolean}
*/
function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
```
- [ ] **Step 2: Add validation in applySettings()**
Replace lines 92-94:
```javascript
if (settings.bgUrl) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
}
```
With:
```javascript
if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
} else if (settings.bgUrl) {
// Ungueltige URL im Storage — bereinigen
settings.bgUrl = '';
}
```
- [ ] **Step 3: Add validation in the URL-input handler (btnApplyBg)**
Replace lines 169-175:
```javascript
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings();
document.getElementById('bgInputRow').classList.add('hidden');
});
```
With:
```javascript
document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim();
if (url && !isValidBgUrl(url)) {
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
return;
}
settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings();
document.getElementById('bgInputRow').classList.add('hidden');
});
```
- [ ] **Step 4: Verify the file upload handler is already safe**
Read `settings.js:181-194`. The `FileReader.readAsDataURL(file)` produces a `data:image/...` string, which passes `isValidBgUrl()`. The handler at line 186 sets `settings.bgUrl = ev.target.result` — this is already valid output. No change needed here.
- [ ] **Step 5: Add i18n keys for the validation error dialog**
These keys will be added in Task 8 together with all other i18n keys. For now, note that we need:
- `settings.bg_invalid_url` — "Nur lokale Bilder (Upload) sind als Hintergrund erlaubt." / "Only local images (upload) are allowed as background."
- `settings.bg_invalid_url.title` — "Ungültige URL" / "Invalid URL"
- [ ] **Step 6: Verify background upload still works**
Reload extension. Test:
1. Open Theme Modal → upload a local image → verify it displays as background
2. Try entering `javascript:alert(1)` in the URL input → verify it's rejected with a dialog
3. Reload → verify the uploaded background persists
- [ ] **Step 7: Commit**
```bash
git add src/js/settings.js
git commit -m "fix(security): validate background URL before CSS injection
Add isValidBgUrl() that only allows blob: and data:image/ protocols.
Applied in applySettings() and the manual URL input handler.
Prevents CSS injection via manipulated bgUrl storage values."
```
---
### Task 5: Security + Quality — Data Import Hardening
**Files:**
- Modify: `src/js/data.js:33-127`
- [ ] **Step 1: Add isSafeUrl() helper at top of data.js**
Add after the `initDataButtons` function declaration (after line 6, before the function body):
Actually, add it inside the function before the event listeners, right after `if (!btnExport || !btnImport) return;` (after line 10):
```javascript
/**
* Prueft ob eine URL ein sicheres Protokoll hat.
* Blockiert javascript:, data:, vbscript: etc.
* @param {string} url
* @returns {boolean}
*/
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
```
- [ ] **Step 2: Replace the mutable board/bookmark filter with immutable mapping**
Replace lines 41-52 (the `validBoards` filter block):
```javascript
const validBoards = data.boards
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
.map(b => ({
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
bookmarks: b.bookmarks
.filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
.map(bm => ({
id: bm.id || uid(),
title: String(bm.title).slice(0, 200),
url: bm.url,
desc: String(bm.desc || '').slice(0, 500)
}))
}));
```
- [ ] **Step 3: Replace the mutable notes filter with immutable mapping**
Replace lines 68-71 (the `importNotes` filter):
```javascript
const importNotes = data.notes
.filter(n => n && n.id && n.template)
.map(n => ({
id: n.id,
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
x: typeof n.x === 'number' ? n.x : 120,
y: typeof n.y === 'number' ? n.y : 80,
width: typeof n.width === 'number' ? n.width : 280,
height: typeof n.height === 'number' ? n.height : 220,
open: n.open !== false,
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
```
- [ ] **Step 4: Replace direct Notes._notes mutation with Notes.init()**
Replace lines 76-81:
```javascript
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged;
Notes._notes = merged;
notesImported = toImport.length;
}
```
With:
```javascript
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged;
notesImported = toImport.length;
}
```
Then after line 113 (`await Store.set('widgetStates', existingWidgets);`), add:
```javascript
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
if (notesImported > 0) await Notes.init();
if (calcImported) await Calculator.load();
if (timerImported) await Timer.load();
```
And remove the direct mutations at lines 93 and 107:
- Remove: `Calculator._history = existingWidgets.calculator.history;` (line 93)
- Remove: `Timer._presets = existingWidgets.timer.presets;` (line 107)
- [ ] **Step 5: Verify import functionality**
Reload extension. Test:
1. Export current data as JSON
2. Edit the exported JSON: add a bookmark with `javascript:alert(1)` URL → import → verify the bad bookmark is silently skipped
3. Import a normal JSON backup → verify boards, notes, calculator history, timer presets all appear correctly
4. Verify no console errors
- [ ] **Step 6: Commit**
```bash
git add src/js/data.js
git commit -m "fix(security): harden JSON import with URL validation and immutable mapping
Add isSafeUrl() to block javascript:/data: URLs in imported bookmarks.
Replace mutable object mutation with immutable .map() and string length limits.
Use Notes.init()/Calculator.load()/Timer.load() instead of direct _notes/_history
mutation after import."
```
---
### Task 6: Remove Google Favicons — Local Letter Icons
**Files:**
- Modify: `src/js/state.js:36-43` (remove `getFaviconUrl`)
- Modify: `src/js/boards.js:218-230` (replace favicon rendering)
- Modify: `src/css/main.css:565-571` (replace CSS classes)
- [ ] **Step 1: Remove getFaviconUrl() from state.js**
Delete lines 36-43 in `src/js/state.js`:
```javascript
function getFaviconUrl(url) {
try {
const u = new URL(url);
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
} catch {
return '';
}
}
```
- [ ] **Step 2: Replace favicon rendering in boards.js**
Replace lines 218-230 in `src/js/boards.js`:
```javascript
const favicon = document.createElement('img');
favicon.className = 'bm-favicon';
favicon.width = 14;
favicon.height = 14;
favicon.src = getFaviconUrl(bm.url);
favicon.addEventListener('error', function() {
this.classList.add('hidden');
this.nextElementSibling.classList.remove('hidden');
});
const fallback = document.createElement('div');
fallback.className = 'bm-favicon-fallback hidden';
fallback.textContent = bm.title.charAt(0).toUpperCase();
```
With:
```javascript
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
```
Also update the `appendChild` calls below. The old code appends both `favicon` and `fallback`:
Find the line that appends the fallback (should be near line 243-244):
```javascript
li.append(favicon, fallback, textDiv, deleteBtn);
```
Replace with:
```javascript
li.append(favicon, textDiv, deleteBtn);
```
- [ ] **Step 3: Replace CSS classes in main.css**
Replace lines 565-571:
```css
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; }
.bm-favicon-fallback {
width: 14px; height: 14px; flex-shrink: 0;
background: var(--accent-dim); border-radius: 2px;
display: flex; align-items: center; justify-content: center;
font-size: 8px; color: var(--accent);
}
```
With:
```css
.bm-favicon-local {
width: 16px; height: 16px; flex-shrink: 0;
border-radius: 3px;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 600;
color: #fff;
line-height: 1;
}
```
- [ ] **Step 4: Verify favicons display correctly**
Reload extension. Check:
1. All bookmarks show a colored letter icon
2. Different bookmark titles produce different colors
3. The icons are aligned and properly sized in all themes
4. No network requests to google.com in the Network tab (F12 → Network)
5. No console errors about `getFaviconUrl`
- [ ] **Step 5: Commit**
```bash
git add src/js/state.js src/js/boards.js src/css/main.css
git commit -m "feat(privacy): replace Google Favicons with local letter icons
Remove getFaviconUrl() and all external network requests. Bookmarks now
show a colored letter icon with deterministic hue based on title.
Eliminates privacy leak and Brave Shields compatibility issues."
```
---
### Task 7: backdrop-filter Fallback for Brave Shields
**Files:**
- Modify: `src/css/main.css` (add `--bg-solid-fallback` per theme + `@supports not` block)
- [ ] **Step 1: Add --bg-solid-fallback to each theme**
Add the variable to each theme's `[data-theme]` block. The value is an opaque version of `--bg-board`:
| Theme | Line | `--bg-solid-fallback` value |
|---|---|---|
| nebula | ~82 | `#0a060e` |
| crescent | ~108 | `#0c0b08` |
| event-horizon | ~137 | `#06040f` |
| merchantman | ~163 | `#040d0d` |
| julia-jin | ~189 | `#080c12` |
| sc-sunset | ~216 | `#0e0808` |
| hellion-hud | ~245 | `#04080c` |
| hellion-energy | ~278 | `#040a08` |
| satisfactory | ~310 | `#060a0c` |
| avorion | ~341 | `#040c0a` |
| hellion-stealth | ~371 | `#060a0e` |
Add `--bg-solid-fallback: <value>;` as the last variable in each theme block.
- [ ] **Step 2: Add @supports not block at the end of the general layout section**
Add after the existing board/widget styles, before the theme-specific sections (around line 75, before the first `[data-theme]` block):
```css
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
@supports not (backdrop-filter: blur(1px)) {
.board,
.widget,
.settings-panel,
.dialog-box,
.theme-modal,
.search-bar {
background-color: var(--bg-solid-fallback, var(--bg-primary));
}
}
```
- [ ] **Step 3: Verify fallback works**
Test in Brave with Shields set to aggressive. Or test by temporarily adding this CSS rule:
```css
.board { backdrop-filter: none !important; }
```
Verify that boards still have a visible background (opaque, not transparent).
- [ ] **Step 4: Commit**
```bash
git add src/css/main.css
git commit -m "fix(compat): add backdrop-filter fallback for Brave Shields
Add --bg-solid-fallback CSS variable to all 11 themes and a
@supports not (backdrop-filter) block. UI remains usable when
Brave Shields or strict fingerprinting settings block backdrop-filter."
```
---
### Task 8: Complete i18n Coverage
**Files:**
- Modify: `newtab.html:26-42` (add `data-i18n-title` to 5 header buttons)
- Modify: `newtab.html:198, 215, 374` (add `data-i18n` to 3 setting buttons)
- Modify: `src/js/i18n.js` (add 10 new keys — 8 from spec + 2 from Task 4)
- [ ] **Step 1: Add data-i18n-title to header buttons in newtab.html**
Line 26 — change:
```html
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)">
```
To:
```html
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
```
Line 30 — change:
```html
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen">
```
To:
```html
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
```
Line 34 — change:
```html
<button class="btn-icon" id="btnNote" title="Schnellnotiz">
```
To:
```html
<button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
```
Line 38 — change:
```html
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
```
To:
```html
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
```
Line 42 — change:
```html
<button class="btn-icon" id="btnSettings" title="Einstellungen">
```
To:
```html
<button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
```
- [ ] **Step 2: Add data-i18n to settings buttons in newtab.html**
Line 198 — change:
```html
<button class="btn-small" id="btnRestartOnboarding">Start</button>
```
To:
```html
<button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
```
Line 215 — change:
```html
<button class="btn-danger" id="btnResetAll">Reset</button>
```
To:
```html
<button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
```
Line 374 — change:
```html
<button class="btn-small" id="btnBgFile">Upload</button>
```
To:
```html
<button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
```
- [ ] **Step 3: Add new keys to STRINGS.de in i18n.js**
Add these keys to the `STRINGS.de` object, in the appropriate sections:
In the Header section:
```javascript
'header.import_title': 'Bookmarks importieren (HTML)',
'header.board_title': 'Neues Board hinzufügen',
'header.note_title': 'Schnellnotiz',
'header.theme_title': 'Darstellung & Theme',
'header.settings_title': 'Einstellungen',
```
In the Settings section:
```javascript
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url.title': 'Ungültige URL',
```
- [ ] **Step 4: Add new keys to STRINGS.en in i18n.js**
Add the matching English keys to `STRINGS.en`:
In the Header section:
```javascript
'header.import_title': 'Import bookmarks (HTML)',
'header.board_title': 'Add new board',
'header.note_title': 'Quick note',
'header.theme_title': 'Appearance & Theme',
'header.settings_title': 'Settings',
```
In the Settings section:
```javascript
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
'settings.bg_invalid_url.title': 'Invalid URL',
```
- [ ] **Step 5: Verify translations**
Reload extension. Test:
1. Set language to English → hover over header buttons → verify English tooltips
2. Set language to German → hover → verify German tooltips
3. Open Settings → verify "Start", "Reset", "Upload" buttons have `data-i18n` attributes (inspect in DevTools)
- [ ] **Step 6: Commit**
```bash
git add newtab.html src/js/i18n.js
git commit -m "fix(i18n): complete missing translations for toolbar tooltips and button texts
Add data-i18n-title to 5 header buttons, data-i18n to 3 settings buttons.
Add 10 new keys to STRINGS.de and STRINGS.en including background URL
validation error messages."
```
---
### Task 9: Version Bump, Changelog, Clock Cleanup
**Files:**
- Modify: `src/js/app.js:135`
- Modify: `manifest.json:5`
- Modify: `manifest.firefox.json` (version field)
- Modify: `manifest.opera.json` (version field)
- Modify: `CHANGELOG.md`
- [ ] **Step 1: Store clock interval ID in app.js**
Replace line 135 in `src/js/app.js`:
```javascript
setInterval(tick, 1000);
```
With:
```javascript
const clockInterval = setInterval(tick, 1000);
```
- [ ] **Step 2: Bump version in all three manifests**
In `manifest.json`, `manifest.firefox.json`, and `manifest.opera.json`, change:
```json
"version": "2.0.0",
```
To:
```json
"version": "2.0.1",
```
- [ ] **Step 3: Add CHANGELOG entry**
Add this block at the top of `CHANGELOG.md`, after the header and before the v2.0.0 entry:
```markdown
### v2.0.1 — 16.04.2026
#### Security
- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage)
- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import
- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits
#### Fixed
- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch
- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes`
- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys)
#### Changed
- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching
- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible
- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility
- **Clock interval cleanup** — `setInterval` ID stored in variable
---
```
- [ ] **Step 4: Verify everything**
Full manual test:
1. Reload extension
2. Verify version in `chrome://extensions` shows 2.0.1
3. Open/close/minimize/reopen widgets of all types
4. Switch language DE/EN — all tooltips translate
5. Import/export JSON data
6. Upload background image
7. Check Network tab — zero external requests
8. Check Console — zero errors
- [ ] **Step 5: Commit**
```bash
git add src/js/app.js manifest.json manifest.firefox.json manifest.opera.json CHANGELOG.md
git commit -m "chore(release): bump version to v2.0.1 — hardening release
Security fixes, widget event system, local favicons, i18n completeness,
backdrop-filter fallback, code quality improvements. See CHANGELOG.md."
```
@@ -0,0 +1,581 @@
# Hellion NewTab — Calculator Upgrade Design
**Datum:** 2026-04-16
**Autor:** Florian Wathling / Claude Code
**Status:** Approved
**Scope:** Calculator erweitern um Scientific, Unit-Converter und Game-Rechner (Satisfactory, Factorio, Stationeers)
**Ziel-Version:** v2.1.0
---
## Kontext
Der Calculator ist aktuell ein reiner Grundrechenarten-Taschenrechner (720 Zeilen, Shunting-Yard Parser, 4x5 Button-Grid, History). Das Upgrade macht ihn zum zentralen Tool-Widget mit 6 Modi:
1. **Standard** (bestehend)
2. **Scientific** (Wurzel, Potenz, Pi, Formel-Helfer)
3. **Unit-Converter** (Länge, Gewicht, Temperatur, Volumen, Geschwindigkeit, Fläche)
4. **Satisfactory** (Items/Min, Overclock-Power, Maschinen-Rechner)
5. **Factorio** (Assembler-Ratios, Belt-Throughput, Maschinen-Rechner)
6. **Stationeers** (Idealgas, Furnace/Verbrennung, Solar/Batterie, Atmosphäre)
---
## Sektion 1: Architektur und Dateistruktur
### Datei-Aufteilung
```
src/js/
├── calculator.js # Core: Tab-System, Standard-Modus, erweiterter Shunting-Yard Parser
├── calc-scientific.js # Scientific-Modus
├── calc-converter.js # Unit-Converter
├── calc-satisfactory.js # Satisfactory Calculator
├── calc-factorio.js # Factorio Calculator
└── calc-stationeers.js # Stationeers Calculator
```
### Load-Order in newtab.html
```
... → widgets.js → notes.js → calculator.js → calc-scientific.js → calc-converter.js →
calc-satisfactory.js → calc-factorio.js → calc-stationeers.js → timer.js → ...
```
Alle Mode-Dateien laden nach `calculator.js` und vor `timer.js`. Kein zirkulärer Dependency-Konflikt.
### Registrierungs-Pattern
Jede Mode-Datei registriert sich beim Calculator-Objekt:
```javascript
Calculator.registerMode('scientific', {
label: '\uD83D\uDCD0', // Icon
shortName: 'Sci', // Tab-Label (3 Zeichen)
titleKey: 'calculator.tab.scientific', // i18n-Key
render(bodyEl) { /* UI aufbauen */ },
destroy() { /* Cleanup, Event-Listener entfernen */ }
});
```
`calculator.js` bekommt:
```javascript
_modes: new Map(),
_activeMode: 'standard',
registerMode(name, config) {
this._modes.set(name, config);
},
```
Die Tab-Leiste wird dynamisch aus `_modes` gebaut. Standard-Modus ist immer registriert (intern, nicht per externer Datei). Die anderen Modi kommen dazu wenn ihre Script-Datei geladen ist.
### Tab-Wechsel
```javascript
switchMode(name) {
const mode = this._modes.get(name);
if (!mode) return;
this._activeMode = name;
const body = WidgetManager.getBody(this.WIDGET_ID);
if (!body) return;
// Alten Modus aufräumen
const oldMode = this._modes.get(this._previousMode);
if (oldMode && oldMode.destroy) oldMode.destroy();
// Neuen Modus rendern
body.textContent = '';
mode.render(body);
// Tab-UI aktualisieren
this._updateTabBar();
// State speichern
this.save();
}
```
### Storage
Jeder Modus speichert seinen State als Sub-Key unter `calculator` im bestehenden `widgetStates`:
```javascript
{
calculator: {
x: 400, y: 120, width: 320, height: 480,
open: true,
activeMode: 'standard',
history: [{ expr: '42 × 7', result: '294' }],
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' },
satisfactory: { lastSubMode: 'itemsPerMin' },
factorio: { lastSubMode: 'ratio', lastAssembler: 'asm3' },
stationeers: { lastSubMode: 'gas' }
}
}
```
Read-before-write Pattern bleibt: `const data = await Store.get(this.STORAGE_KEY) || {};`
---
## Sektion 2: Standard-Modus (Änderungen)
### Parser-Erweiterung
Der Shunting-Yard Parser wird um zwei Operationen erweitert:
**Potenz-Operator `^`:**
- Binärer Operator mit höchster Precedence (über `*` und `/`)
- Rechts-assoziativ: `2^3^2` = `2^(3^2)` = 512
- Tokenizer erkennt `^` als `{ type: 'op', value: '^' }`
- parseFactor() → parsePower() → parseFactor() (neue Precedence-Stufe)
**Wurzel-Funktion `sqrt`:**
- Wird vom Scientific-Modus als `sqrt(` in die Expression eingefügt
- Tokenizer erkennt `sqrt` als `{ type: 'func', value: 'sqrt' }`
- parseFactor() prüft auf Functions vor Numbers
Die bestehende Operator-Hierarchie wird:
```
parseExpr: + -
parseTerm: * / %
parsePower: ^ ← NEU
parseFactor: number | (expr) | func(expr) ← func NEU
```
### Keine Änderungen am Standard-UI
Das 4x5 Button-Grid, History-Panel und Keyboard-Support bleiben identisch. Die Parser-Erweiterung ist rückwärtskompatibel (keine bestehende Expression bricht).
---
## Sektion 3: Scientific-Modus
### Zusätzliche Buttons
2 neue Reihen über dem Standard-Grid:
| Button | Wert | Aktion |
|---|---|---|
| √ | `sqrt(` | Unäre Funktion, öffnet Klammer |
| x² | `^2` | Hängt `^2` an Expression |
| xⁿ | `^` | Fügt Potenz-Operator ein |
| π | `3.14159265359` | Konstante einfügen |
| e | `2.71828182846` | Konstante einfügen |
| ± | toggle | Vorzeichen des letzten Werts wechseln |
Darunter das Standard 4x5-Grid (C, Klammern, %, ÷, 0-9, Operatoren, =). Der Scientific-Modus nutzt den gleichen `_handleKey()`/`_calculate()`-Flow.
### Formel-Helfer
Ein Dropdown unter dem Button-Grid mit vorgefertigten Formeln:
| Formel | Eingabefelder | Berechnung |
|---|---|---|
| Kreis-Fläche | Radius (r) | × r²` |
| Kreis-Umfang | Radius (r) | `2 × π × r` |
| °C → °F | Temperatur | `(C × 9/5) + 32` |
| °F → °C | Temperatur | `(F - 32) × 5/9` |
| Pythagoras | a, b | `√(a² + b²)` |
| Prozent-Wert | Wert, Prozent | `Wert × Prozent / 100` |
Jede Formel öffnet inline Eingabefelder + Live-Ergebnis. Nutzt `_formatResult()` für einheitliche Zahlenformatierung.
### Keyboard
Gleicher Keyboard-Support wie Standard-Modus, plus:
- `p` → Pi einfügen
- `e` → Euler einfügen (kein Konflikt: `e` ist im Standard nicht belegt, nur `c`/`C` und `Escape` sind Clear)
---
## Sektion 4: Unit-Converter
### UI-Aufbau
```
┌──────────────────────────┐
│ [Kategorie-Dropdown ▼]│
│ │
│ [123.45 ] [cm ▼] │
│ ⇅ (Swap-Button) │
│ [48.622 ] [in ▼] │
│ │
│ Schnellreferenz: │
│ 1 cm = 0.3937 in │
│ 1 in = 2.54 cm │
└──────────────────────────┘
```
### Kategorien und Einheiten
| Kategorie | Einheiten | Basis-Einheit |
|---|---|---|
| Länge | mm, cm, m, km, in, ft, yd, mi | m |
| Gewicht | mg, g, kg, t, oz, lb | g |
| Temperatur | °C, °F, K | (Spezialfunktionen) |
| Volumen | ml, L, m³, gal(US), gal(UK), ft³ | ml |
| Geschwindigkeit | m/s, km/h, mph, kn | m/s |
| Fläche | mm², cm², m², km², ha, acre, ft², in² | m² |
### Konvertierungs-Logik
Jede Einheit hat `toBase(value)` und `fromBase(value)`:
```javascript
const LENGTH_UNITS = {
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
m: { toBase: v => v, fromBase: v => v },
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
};
```
Temperatur bekommt eigene Funktionen (nicht linear):
```javascript
const TEMP_CONVERSIONS = {
'C_F': v => (v * 9/5) + 32,
'C_K': v => v + 273.15,
'F_C': v => (v - 32) * 5/9,
'F_K': v => (v - 32) * 5/9 + 273.15,
'K_C': v => v - 273.15,
'K_F': v => (v - 273.15) * 9/5 + 32
};
```
### Verhalten
- Live-Update bei Eingabe (kein "Berechnen"-Button)
- Swap-Button (⇅) tauscht Quell- und Ziel-Einheit
- Schnellreferenz zeigt `1 [from] = x [to]` und umgekehrt
- Kein Keyboard-Override (native `<input>` Felder)
### Storage
```javascript
converter: { lastCategory: 'length', fromUnit: 'cm', toUnit: 'in' }
```
---
## Sektion 5: Satisfactory Calculator
### Sub-Modi
Drei Buttons oben wählen den aktiven Rechner:
#### 5a: Items/Min
**Eingabefelder:**
- Items per Craft (default: 1)
- Craft Time in Sekunden (default: 4)
- Clock Speed in % (default: 100)
**Formel:**
```
Output = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
```
**Ausgabe:** `X.XX items/min`
#### 5b: Overclock Power
**Eingabefelder:**
- Base Power in MW (default: 30)
- Clock Speed in % (default: 100)
**Formeln:**
```
PowerUsage = BasePower × (ClockSpeed / 100) ^ 1.321928
EnergyPerItem = (ClockSpeed / 100) ^ 0.321928
```
**Ausgabe:**
- `Power Usage: X.X MW`
- `Efficiency: ↓ X.X% per item` (nur bei ClockSpeed > 100)
#### 5c: Maschinen
**Eingabefelder:**
- Target Output/Min (default: 60)
- Items per Craft (default: 1)
- Craft Time in Sekunden (default: 4)
- Clock Speed in % (default: 100)
- Base Power in MW (default: 30)
**Formeln:**
```
ItemsPerMin = (ItemsPerCraft × 60) / CraftTime × (ClockSpeed / 100)
Machines = ceil(TargetOutput / ItemsPerMin)
TotalPower = Machines × BasePower × (ClockSpeed / 100) ^ 1.321928
```
**Ausgabe:**
- `Machines needed: X`
- `Total Power: X.X MW`
### Verhalten
Alle Felder berechnen live. `<input type="number">` mit `step`-Attribut für sinnvolle Schrittweiten.
---
## Sektion 6: Factorio Calculator
### Sub-Modi
#### 6a: Assembler-Ratio
**Eingabefelder:**
- Assembler-Dropdown: Assembler 1 (0.5), Assembler 2 (0.75), Assembler 3 (1.25)
- Recipe Output Count (default: 1)
- Recipe Time in Sekunden (default: 1)
**Formel:**
```
OutputPerSecond = RecipeOutput × CraftingSpeed / RecipeTime
OutputPerMinute = OutputPerSecond × 60
```
**Ausgabe:**
- `X.XX items/s`
- `X.XX items/min`
#### 6b: Belt-Throughput
**Eingabefelder:**
- Belt-Dropdown: Yellow (15/s), Red (30/s), Blue (45/s)
- Items consumed per second per machine (default: 1)
**Feste Werte:**
| Belt | Total (items/s) | Per Side (items/s) |
|---|---|---|
| Yellow | 15 | 7.5 |
| Red | 30 | 15 |
| Blue | 45 | 22.5 |
**Formel:**
```
MachinesPerBelt = floor(BeltThroughput / ItemsConsumedPerSec)
Utilization = (ItemsConsumedPerSec × MachinesPerBelt) / BeltThroughput × 100
```
**Ausgabe:**
- `Machines per belt: X`
- `Belt utilization: X%`
#### 6c: Maschinen
**Eingabefelder:**
- Assembler-Dropdown
- Target Output/s (default: 10)
- Recipe Output Count (default: 1)
- Recipe Time in Sekunden (default: 1)
**Formel:**
```
OutputPerMachine = RecipeOutput × CraftingSpeed / RecipeTime
Machines = ceil(TargetOutput / OutputPerMachine)
TotalThroughput = Machines × OutputPerMachine
BeltNeeded = kleinster Belt der TotalThroughput schafft
```
**Ausgabe:**
- `Machines needed: X`
- `Belt needed: [Color] (X% utilization)`
---
## Sektion 7: Stationeers Calculator
### Sub-Modi
Vier Buttons oben (statt drei wie bei den anderen Game-Rechnern).
#### 7a: Gas (Idealgas PV=nRT)
**Eingabefelder:**
- Dropdown: Gesucht = P, V, n oder T
- Die drei anderen Variablen als Eingabefelder
**Konstante:** R = 8314.46261815324 (Stationeers-spezifisch, Einheit: L·Pa / mol·K)
**Formeln:**
```
P = nRT / V
V = nRT / P
n = PV / RT
T = PV / nR
```
**Eingabe-Einheiten:**
- P in kPa (wird intern × 1000 zu Pa)
- V in Litern
- T in Kelvin (Hilfstext zeigt °C-Äquivalent)
- n in mol
#### 7b: Furnace / Verbrennung
**Eingabefelder:**
- Fuel Ratio (0 bis 1, Anteil Brennstoff am Gesamtgas)
- Start-Temperatur in Kelvin
- Start-Druck in kPa
**Formeln:**
```
T_nach = (T_vor × specificHeat + fuel × 563452) / (specificHeat + fuel × 172.615)
P_nach = P_vor × T_nach × (1 + 5.7 × fuel) / T_vor
```
Wobei:
- `fuel = min(ratioO2, ratioVolatile / 2)`
- `specificHeat` = gewichtete Summe der Gas-Wärmekapazitäten
- Vereinfachung: Fuel Ratio als einzelner Wert (0-1), `specificHeat(before)` wird aus reinem Fuel berechnet (61.9 J/mol·K für 1:2 O₂:H₂ Mischung)
- 563452 = Energie pro Mol bei 95% Effizienz
- 172.615 = 0.95 × (243.6 - 61.9)
**Validierung:**
- Warnung wenn Fuel < 0.05 (unter 5% Minimum)
- Warnung wenn Start-Druck < 10 kPa
**Ausgabe:**
- `T after ignition: X K (X °C)`
- `P after ignition: X kPa`
#### 7c: Solar / Batterie
**Eingabefelder:**
- Anzahl Panels (default: 12)
- Watt pro Panel (default: 500, Mond-Wert)
- Tag-Länge in Sekunden (default: 600)
- Nacht-Länge in Sekunden (default: 600)
- Verbrauch in Watt (default: 2000)
**Formeln:**
```
Generation = Panels × WattsPerPanel
Surplus = Generation - Consumption
NightEnergy = Consumption × NightLength (in Watt-Sekunden)
BatteriesNeeded = ceil(NightEnergy / 50000) (Station Battery = 50.000 Ws)
```
**Ausgabe:**
- `Generation: X W`
- `Surplus: X W` (rot wenn negativ)
- `Night Energy: X Ws`
- `Batteries needed: X`
#### 7d: Atmosphäre / Gas-Mischer
**Eingabefelder:**
- Target Temperatur in Kelvin
- Gas 1 Temperatur in Kelvin
- Gas 2 Temperatur in Kelvin
**Formel:**
```
M1 = |T2 - T0| / (|T1 - T0| + |T2 - T0|)
M2 = 1 - M1
```
**Ausgabe:**
- `Mixer Input 1: X.X%`
- `Mixer Input 2: X.X%`
**Aufklappbare Wärmekapazität-Referenz:**
| Gas | Cp (J/mol·K) |
|---|---|
| O₂ | 21.1 |
| H₂ | 20.4 |
| CO₂ | 28.2 |
| N₂ | 20.6 |
| H₂O | 72.0 |
| N₂O | 23.0 |
| Pollutant | 24.8 |
---
## Sektion 8: UI, i18n und Widget-Sizing
### Tab-Leiste
Horizontale Leiste direkt unter dem Widget-Header. Immer sichtbar (kein Scrollen).
| Tab | Icon | Label |
|---|---|---|
| Standard | 🔢 | Std |
| Scientific | 📐 | Sci |
| Converter | ⚖️ | Unit |
| Satisfactory | ⚙️ | SAT |
| Factorio | 🏭 | FAC |
| Stationeers | 🚀 | STA |
Aktiver Tab: `border-bottom: 2px solid var(--accent)`, Text in `var(--accent)`.
Inaktive Tabs: `color: rgba(255,255,255,0.5)`.
CSS-Klasse: `.calc-tab-bar` und `.calc-tab`.
### Widget-Sizing
- Standard-Modus Minimum: 280 × 400 px
- Komplexe Modi (Scientific, Game-Rechner): Auto-Resize auf 320 × 480 px (falls aktuell kleiner)
- User-Resize überschreibt Auto-Resize
- Widget-System-Minimum bleibt 200 × 150 px
### i18n
Geschätzt ~100 neue Keys in `STRINGS.de` und `STRINGS.en`:
- 6 Tab-Labels
- 6 Kategorie-Namen (Converter)
- ~48 Einheiten-Langformen (Converter)
- ~30 Feld-Labels (Game-Rechner)
- ~10 Ergebnis-Labels
Einheiten-Abkürzungen (cm, kg, °C, kPa) werden nicht übersetzt.
### Keyboard
- Standard-Modus: Bestehender Keyboard-Support (0-9, +, -, *, /, Enter, Backspace, Escape)
- Scientific-Modus: Gleicher Support + `p` (Pi), `^` (Potenz)
- Converter und Game-Modi: Kein Custom-Keyboard (native `<input>` Felder)
---
## Betroffene Dateien (Gesamt)
| Datei | Änderung |
|---|---|
| `src/js/calculator.js` | Tab-System, registerMode(), switchMode(), Parser-Erweiterung (^, sqrt) |
| `src/js/calc-scientific.js` | NEU: Scientific-Modus |
| `src/js/calc-converter.js` | NEU: Unit-Converter |
| `src/js/calc-satisfactory.js` | NEU: Satisfactory Calculator |
| `src/js/calc-factorio.js` | NEU: Factorio Calculator |
| `src/js/calc-stationeers.js` | NEU: Stationeers Calculator |
| `src/css/main.css` | Tab-Bar Styles, Mode-spezifische Styles |
| `src/js/i18n.js` | ~100 neue Keys (DE + EN) |
| `newtab.html` | 5 neue `<script>` Tags in Load-Order |
| `manifest.json` | Version → 2.1.0 |
| `manifest.firefox.json` | Version → 2.1.0 |
| `manifest.opera.json` | Version → 2.1.0 |
| `CHANGELOG.md` | v2.1.0 Eintrag |
## Implementierungsreihenfolge
1. **Calculator Core** — Tab-System, registerMode(), switchMode(), Tab-Bar CSS
2. **Parser-Erweiterung**`^` Operator und `sqrt` Funktion
3. **Scientific-Modus** — Buttons, Formel-Helfer, Registrierung
4. **Unit-Converter** — Kategorien, Einheiten, Konvertierungs-Logik, UI
5. **Satisfactory Calculator** — 3 Sub-Modi, Formeln, UI
6. **Factorio Calculator** — 3 Sub-Modi, Formeln, UI
7. **Stationeers Calculator** — 4 Sub-Modi, Formeln, UI
8. **i18n** — Alle neuen Keys (DE + EN)
9. **Version Bump** — Manifests, CHANGELOG
@@ -0,0 +1,400 @@
# Hellion NewTab v2.0.1 — Hardening Release Design
**Datum:** 2026-04-16
**Autor:** Florian Wathling / Claude Code
**Status:** Approved
**Scope:** Security, Stability, i18n, Code Quality
**Strategie:** Foundation First (Event-System zuerst, dann darauf aufbauen)
---
## Kontext
Umfassender Audit von v2.0.0 hat Findings in vier Kategorien ergeben:
- 3 Sicherheitslücken (HOCH)
- 2 Stabilitätsprobleme (Race Conditions)
- 8 fehlende i18n-Attribute
- 3 Code-Qualität-Items
Dieses Design beschreibt alle Fixes als zusammenhängendes Hardening-Release.
---
## Sektion 1: Widget Event-System
### Problem
Calculator (`calculator.js:692-728`), Timer (`timer.js:723-758`) und ImageRef (`image-ref.js:463-498`) überschreiben `WidgetManager.close`, `.minimize` und `.openWidget` durch Monkey-Patching in ihrer `init()`. Das erzeugt eine 3-stufige Closure-Kette pro Methode. Funktional korrekt, aber fragil und schwer debugbar.
### Lösung
WidgetManager bekommt ein internes Event-System basierend auf `EventTarget`.
**Neue API in `widgets.js`:**
```javascript
_emitter: new EventTarget(),
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
```
**Events:**
| Event | Feuert nach | Detail |
|---|---|---|
| `widget:close` | `entry.el.remove()` + `_widgets.delete(id)` | `{ id }` | **Achtung:** Element bereits entfernt, Listener dürfen nicht auf Widget-Entry zugreifen |
| `widget:minimize` | State-Änderung + Animation + Save | `{ id }` |
| `widget:open` | State-Änderung + Display-Reset + Save | `{ id }` |
**Migration der Widget-Module:**
Das gesamte Monkey-Patching wird ersetzt durch `WidgetManager.on()` Aufrufe:
```javascript
// Beispiel: Calculator.init()
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);
self.save();
}
});
```
ImageRef folgt dem gleichen Pattern, prüft aber per `self._images.some(img => img.id === id)` statt gegen eine feste WIDGET_ID.
**Load-Order:** Kein Problem. `widgets.js` wird vor allen Widget-Modulen geladen. Die Module rufen `WidgetManager.on()` in ihrer `init()` auf, die erst in `app.js` aufgerufen wird.
### Betroffene Dateien
- `src/js/widgets.js` — Event-System hinzufügen, Events in close/minimize/openWidget dispatchen
- `src/js/calculator.js` — Monkey-Patching (Z. 692-728) durch Event-Listener ersetzen
- `src/js/timer.js` — Monkey-Patching (Z. 723-758) durch Event-Listener ersetzen
- `src/js/image-ref.js` — Monkey-Patching (Z. 463-498) durch Event-Listener ersetzen
---
## Sektion 2: Minimize-Animation mit `transitionend`
### Problem
`WidgetManager.minimize()` (`widgets.js:154-163`) setzt `display: none` nach 250ms `setTimeout`. Wenn `openWidget()` in diesen 250ms aufgerufen wird, überschreibt der Timeout das `display: flex` wieder (Race Condition).
### Lösung
`setTimeout` wird durch `transitionend` Event ersetzt. Eine `_minimizing` Flag verhindert die Race Condition.
```javascript
async minimize(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized');
entry.el.addEventListener('transitionend', function onEnd(e) {
if (e.target !== entry.el) return;
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
}
entry._minimizing = false;
}, { once: false });
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
await this.save();
},
async openWidget(id) {
const entry = this._widgets.get(id);
if (!entry) return;
entry._minimizing = false; // Race Condition verhindert
entry.state.open = true;
entry.el.style.display = 'flex';
requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized');
});
this.bringToFront(id);
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
await this.save();
},
```
**Warum `_minimizing` Flag:** Robuster als `clearTimeout`, weil sie unabhängig von der CSS-Transition-Duration funktioniert.
**Fallback:** Falls `transitionend` nicht feuert (kein Transition definiert), bleibt das Widget sichtbar mit der Klasse. Akzeptabel, da alle Widgets in `main.css` eine Transition haben.
### Betroffene Dateien
- `src/js/widgets.js``minimize()` und `openWidget()` umschreiben
---
## Sektion 3: Security Fixes
### 3a: URL-Injection in backgroundImage
**Datei:** `src/js/settings.js:93`
**Problem:** `settings.bgUrl` wird unvalidiert in CSS-Template-Literal eingefügt.
**Fix:** Protokoll-Whitelist. Nur `blob:` und `data:image/` erlauben (die einzigen Protokolle die der Upload erzeugt).
```javascript
function isValidBgUrl(url) {
return typeof url === 'string' &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
```
Validierung an zwei Stellen: `applySettings()` und beim Speichern nach Upload.
### 3b: URL-Validierung beim JSON-Import
**Datei:** `src/js/data.js:45-49`
**Problem:** Importierte Bookmark-URLs werden nicht auf Protokoll geprüft. `javascript:` oder `data:` URLs kommen durch.
**Fix:** Protokoll-Whitelist für importierte URLs.
```javascript
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
```
Integration in die Bookmark-Filter-Logik: `if (!bm || typeof bm.title !== 'string' || !isSafeUrl(bm.url)) return false;`
Ungültige Bookmarks werden still übersprungen.
### 3c: Objekt-Mutation im Import
**Datei:** `src/js/data.js:43-48`
**Problem:** `b.id = b.id || uid()` mutiert das geparste JSON-Objekt direkt. Keine Längenvalidierung.
**Fix:** Immutable Mapping mit expliziter Feldauswahl und String-Längen-Limits.
```javascript
.map(bm => ({
id: bm.id || uid(),
title: String(bm.title).slice(0, 200),
url: bm.url,
desc: String(bm.desc || '').slice(0, 500)
}));
```
Analog für Boards:
```javascript
.map(b => ({
id: b.id || uid(),
title: String(b.title).slice(0, 100),
blurred: !!b.blurred,
bookmarks: /* bereits sanitized, siehe oben */
}));
```
Notes-Felder beim Import werden ebenfalls sanitized:
```javascript
.filter(n => n && n.id && n.template)
.map(n => ({
id: n.id,
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
```
### Betroffene Dateien
- `src/js/settings.js``isValidBgUrl()` + Validierung in `applySettings()`
- `src/js/data.js``isSafeUrl()` + immutable Mapping + Längen-Limits
---
## Sektion 4: Lokale Favicons
### Problem
`getFaviconUrl()` (`state.js:36-43`) ruft Google Favicons API auf. Brave Shields blockiert das. Jeder Bookmark erzeugt einen fehlgeschlagenen Netzwerk-Request. Zusätzlich leakt jeder Hostname an Google.
### Lösung
Kein externer Request mehr. `getFaviconUrl()` wird entfernt. Bookmarks zeigen ein farbiges Buchstaben-Icon (erster Buchstabe des Titels).
**state.js:** `getFaviconUrl()` löschen.
**boards.js:** Statt `<img>` + Error-Fallback nur noch ein `<div>`:
```javascript
const favicon = document.createElement('div');
favicon.className = 'bm-favicon-local';
favicon.textContent = bm.title.charAt(0).toUpperCase();
// Deterministische Farbe pro Buchstabe
const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
```
Inline-Style für `backgroundColor` ist hier gerechtfertigt, weil der Wert dynamisch pro Bookmark berechnet wird. Restliche Styles (Größe, Border-Radius, Schrift) kommen aus CSS.
**main.css:** `.bm-favicon` und `.bm-favicon-fallback` ersetzen durch `.bm-favicon-local`.
### Was entfällt
- `getFaviconUrl()` in `state.js`
- `<img class="bm-favicon">` Erzeugung in `boards.js`
- Error-Listener für Favicon-Loads
- `.bm-favicon` und `.bm-favicon-fallback` CSS-Regeln
- Der einzige externe Netzwerk-Request der Extension
### Betroffene Dateien
- `src/js/state.js``getFaviconUrl()` entfernen
- `src/js/boards.js` — Favicon-Rendering umbauen
- `src/css/main.css` — CSS-Klassen tauschen
---
## Sektion 5: i18n-Lücken schließen
### 5a: Toolbar-Buttons — fehlende `data-i18n-title`
Fünf Header-Buttons (`newtab.html:26-42`) haben hardcodierte deutsche `title`-Attribute.
| Button | Key | DE | EN |
|---|---|---|---|
| `#btnImport` | `header.import_title` | Bookmarks importieren (HTML) | Import bookmarks (HTML) |
| `#btnAddBoard` | `header.board_title` | Neues Board hinzufügen | Add new board |
| `#btnNote` | `header.note_title` | Schnellnotiz | Quick note |
| `#btnTheme` | `header.theme_title` | Darstellung & Theme | Appearance & Theme |
| `#btnSettings` | `header.settings_title` | Einstellungen | Settings |
**Fix:** `data-i18n-title` Attribute hinzufügen. `applyLanguage()` erkennt diese automatisch.
### 5b: Button-Texte ohne i18n
Drei Settings-Buttons haben hardcodierte Texte.
| Button | Key | DE | EN |
|---|---|---|---|
| `#btnRestartOnboarding` | `settings.onboarding_btn` | Start | Start |
| `#btnResetAll` | `settings.reset_btn` | Reset | Reset |
| `#btnBgFile` | `settings.bg_upload_btn` | Upload | Upload |
Aktuell in beiden Sprachen identisch, aber `data-i18n` wird für Konsistenz und zukünftige Erweiterbarkeit gesetzt.
### Betroffene Dateien
- `newtab.html` — 5x `data-i18n-title`, 3x `data-i18n` hinzufügen
- `src/js/i18n.js` — 8 neue Keys in `STRINGS.de` und `STRINGS.en`
---
## Sektion 6: Code-Qualität
### 6a: Notes-Mutation beim Import
**Datei:** `src/js/data.js:~79`
**Problem:** `Notes._notes = merged` setzt das interne Array direkt, umgeht `Notes.save()`.
**Fix:** Nach dem Speichern in `widgetStates` wird `Notes.init()` aufgerufen statt das interne Array direkt zu manipulieren.
```javascript
existingWidgets.notes = merged;
await Store.set('widgetStates', existingWidgets);
await Notes.init(); // Neu aus Storage laden + UI rendern
```
### 6b: `backdrop-filter` Fallback
**Datei:** `src/css/main.css`
**Problem:** 24 Stellen mit `backdrop-filter`. Brave Shields kann das blockieren.
**Fix:** Zentraler `@supports not` Block mit solidem Hintergrund-Fallback:
```css
@supports not (backdrop-filter: blur(1px)) {
.board,
.widget,
.settings-panel,
.dialog-box,
.theme-modal {
background-color: var(--bg-solid-fallback);
}
}
```
Jedes Theme bekommt `--bg-solid-fallback` als deckende Variante der Glassmorphism-Farbe.
### 6c: Clock Interval Cleanup
**Datei:** `src/js/app.js:135`
**Problem:** `setInterval(tick, 1000)` ID wird nicht gespeichert.
**Fix:** Interval-ID in Variable speichern. Niedrigste Priorität, da der Interval mit dem Tab stirbt.
```javascript
let _clockInterval = null;
_clockInterval = setInterval(tick, 1000);
```
### Betroffene Dateien
- `src/js/data.js` — Notes-Import über `Notes.init()` statt direkter Mutation
- `src/css/main.css``@supports not` Block + `--bg-solid-fallback` pro Theme
- `src/js/app.js` — Interval-ID speichern
---
## Implementierungsreihenfolge (Foundation First)
1. **Event-System** in `widgets.js` bauen
2. **Widget-Module** auf Events migrieren (`calculator.js`, `timer.js`, `image-ref.js`)
3. **Minimize mit `transitionend`** in `widgets.js`
4. **Security Fixes** in `settings.js` und `data.js`
5. **Lokale Favicons** in `state.js`, `boards.js`, `main.css`
6. **i18n-Lücken** in `newtab.html` und `i18n.js`
7. **Code-Qualität** in `data.js`, `main.css`, `app.js`
8. **Version Bump** auf 2.0.1 in allen drei Manifests + CHANGELOG
## Betroffene Dateien (Gesamt)
| Datei | Sektionen |
|---|---|
| `src/js/widgets.js` | 1, 2 |
| `src/js/calculator.js` | 1 |
| `src/js/timer.js` | 1 |
| `src/js/image-ref.js` | 1 |
| `src/js/settings.js` | 3a |
| `src/js/data.js` | 3b, 3c, 6a |
| `src/js/state.js` | 4 |
| `src/js/boards.js` | 4 |
| `src/js/i18n.js` | 5 |
| `src/js/app.js` | 6c |
| `src/css/main.css` | 4, 6b |
| `newtab.html` | 5 |
| `manifest.json` | 8 |
| `manifest.firefox.json` | 8 |
| `manifest.opera.json` | 8 |
| `CHANGELOG.md` | 8 |
+4 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.0.0", "version": "2.1.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
@@ -34,6 +34,9 @@
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
], ],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png", "48": "assets/icons/icon48.png",
+4 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.0.0", "version": "2.1.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
@@ -19,6 +19,9 @@
"matches": ["<all_urls>"] "matches": ["<all_urls>"]
} }
], ],
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png", "48": "assets/icons/icon48.png",
+4 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3, "manifest_version": 3,
"name": "__MSG_extName__", "name": "__MSG_extName__",
"default_locale": "en", "default_locale": "en",
"version": "2.0.0", "version": "2.1.0",
"description": "__MSG_extDesc__", "description": "__MSG_extDesc__",
"author": "Hellion Online Media - Florian Wathling", "author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de", "homepage_url": "https://hellion-media.de",
@@ -40,6 +40,9 @@
"default_title": "Hellion Dashboard" "default_title": "Hellion Dashboard"
}, },
"content_security_policy": {
"extension_pages": "script-src 'self'; object-src 'self'"
},
"icons": { "icons": {
"16": "assets/icons/icon16.png", "16": "assets/icons/icon16.png",
"48": "assets/icons/icon48.png", "48": "assets/icons/icon48.png",
+21 -16
View File
@@ -23,23 +23,23 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)"> <button class="btn-icon" id="btnImport" title="Bookmarks importieren (HTML)" data-i18n-title="header.import_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
<span data-i18n="header.import">Import</span> <span data-i18n="header.import">Import</span>
</button> </button>
<button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen"> <button class="btn-icon" id="btnAddBoard" title="Neues Board hinzufügen" data-i18n-title="header.board_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
<span data-i18n="header.board">Board</span> <span data-i18n="header.board">Board</span>
</button> </button>
<button class="btn-icon" id="btnNote" title="Schnellnotiz"> <button class="btn-icon" id="btnNote" title="Schnellnotiz" data-i18n-title="header.note_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
<span data-i18n="header.note">Note</span> <span data-i18n="header.note">Note</span>
</button> </button>
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme"> <button class="btn-icon" id="btnTheme" title="Darstellung & Theme" data-i18n-title="header.theme_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a10 10 0 100 20 4 4 0 01-1-7.9 1 1 0 011-.1h1a2 2 0 002-2V7a5 5 0 00-3-4.5"/><circle cx="7" cy="10" r="1.5"/><circle cx="13" cy="6" r="1.5"/><circle cx="17" cy="10" r="1.5"/><circle cx="9" cy="17" r="1.5"/></svg>
<span data-i18n="header.theme">Darstellung</span> <span data-i18n="header.theme">Darstellung</span>
</button> </button>
<button class="btn-icon" id="btnSettings" title="Einstellungen"> <button class="btn-icon" id="btnSettings" title="Einstellungen" data-i18n-title="header.settings_title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
<span data-i18n="header.settings">Settings</span> <span data-i18n="header.settings">Settings</span>
</button> </button>
@@ -53,7 +53,7 @@
<span id="searchEngineIcon">G</span> <span id="searchEngineIcon">G</span>
</button> </button>
<input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" /> <input type="text" class="search-input" id="searchInput" data-i18n-placeholder="search.placeholder" placeholder="Search the web…" autocomplete="off" />
<button class="search-submit" id="searchSubmit"> <button class="search-submit" id="searchSubmit" data-i18n-title="search.submit_title">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button> </button>
</div> </div>
@@ -86,7 +86,7 @@
<aside class="notebook-panel" id="notebookPanel"> <aside class="notebook-panel" id="notebookPanel">
<div class="notebook-header"> <div class="notebook-header">
<span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span> <span class="notebook-header-title"><span data-i18n="notebook.title">Notebook</span> <span class="notebook-count" id="notebookCount">0 / 5</span></span>
<button class="btn-close" id="btnCloseNotebook"></button> <button class="btn-close" id="btnCloseNotebook" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="notebook-slots" id="notebookSlots"> <div class="notebook-slots" id="notebookSlots">
<!-- dynamisch via JS --> <!-- dynamisch via JS -->
@@ -106,7 +106,7 @@
<aside class="settings-panel" id="settingsPanel"> <aside class="settings-panel" id="settingsPanel">
<div class="panel-header"> <div class="panel-header">
<span data-i18n="settings.title">Einstellungen</span> <span data-i18n="settings.title">Einstellungen</span>
<button class="btn-close" id="btnCloseSettings"></button> <button class="btn-close" id="btnCloseSettings" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="panel-body"> <div class="panel-body">
@@ -195,7 +195,7 @@
<span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span> <span class="setting-label" data-i18n="settings.onboarding">Onboarding wiederholen</span>
<span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span> <span class="setting-desc" data-i18n="settings.onboarding.desc">Willkommens-Tour erneut anzeigen</span>
</div> </div>
<button class="btn-small" id="btnRestartOnboarding">Start</button> <button class="btn-small" id="btnRestartOnboarding" data-i18n="settings.onboarding_btn">Start</button>
</div> </div>
</div> </div>
</section> </section>
@@ -212,7 +212,7 @@
<span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span> <span class="setting-label" data-i18n="settings.reset">Alles zurücksetzen</span>
<span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span> <span class="setting-desc" data-i18n="settings.reset.desc">Löscht alle Boards, Notes und Einstellungen</span>
</div> </div>
<button class="btn-danger" id="btnResetAll">Reset</button> <button class="btn-danger" id="btnResetAll" data-i18n="settings.reset_btn">Reset</button>
</div> </div>
</div> </div>
</section> </section>
@@ -223,7 +223,7 @@
<div class="panel-footer"> <div class="panel-footer">
<div class="about-block"> <div class="about-block">
<div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div> <div class="about-logo" data-i18n="about.title">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 2.0.0 · by Hellion Online Media</div> <div class="about-version">Version 2.1.0 · by Hellion Online Media</div>
<div class="about-links"> <div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link"> <a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
@@ -294,7 +294,7 @@
<div class="theme-modal" id="themeModal"> <div class="theme-modal" id="themeModal">
<div class="modal-header"> <div class="modal-header">
<span data-i18n="modal.theme_header">Darstellung</span> <span data-i18n="modal.theme_header">Darstellung</span>
<button class="btn-close" id="btnCloseTheme"></button> <button class="btn-close" id="btnCloseTheme" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="theme-grid"> <div class="theme-grid">
<div class="theme-card active" data-value="nebula"> <div class="theme-card active" data-value="nebula">
@@ -371,7 +371,7 @@
<span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span> <span class="setting-label" data-i18n="settings.bg_upload">Datei hochladen</span>
<span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span> <span class="setting-desc" data-i18n="settings.bg_upload.desc">Lokales Bild als Hintergrund verwenden</span>
</div> </div>
<button class="btn-small" id="btnBgFile">Upload</button> <button class="btn-small" id="btnBgFile" data-i18n="settings.bg_upload_btn">Upload</button>
<input type="file" id="bgFileInput" accept="image/*" class="hidden" /> <input type="file" id="bgFileInput" accept="image/*" class="hidden" />
</div> </div>
</div> </div>
@@ -439,7 +439,7 @@
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<span data-i18n="modal.new_board">New Board</span> <span data-i18n="modal.new_board">New Board</span>
<button class="btn-close" id="btnCancelBoard"></button> <button class="btn-close" id="btnCancelBoard" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" /> <input type="text" class="text-input full-width" id="newBoardName" data-i18n-placeholder="modal.board_name" placeholder="Board name..." maxlength="40" />
@@ -455,7 +455,7 @@
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<span data-i18n="modal.new_bookmark">New Bookmark</span> <span data-i18n="modal.new_bookmark">New Bookmark</span>
<button class="btn-close" id="btnCancelBookmark"></button> <button class="btn-close" id="btnCancelBookmark" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" /> <input type="text" class="text-input full-width" id="newBmTitle" data-i18n-placeholder="modal.bm_title" placeholder="Title..." maxlength="60" />
@@ -473,7 +473,7 @@
<div class="modal"> <div class="modal">
<div class="modal-header"> <div class="modal-header">
<span data-i18n="modal.rename">Rename</span> <span data-i18n="modal.rename">Rename</span>
<button class="btn-close" id="btnCancelRename"></button> <button class="btn-close" id="btnCancelRename" data-i18n-title="dialog.close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" /> <input type="text" class="text-input full-width" id="renameInput" data-i18n-placeholder="modal.rename_placeholder" placeholder="New name..." maxlength="60" />
@@ -507,6 +507,11 @@
<script src="src/js/widgets.js"></script> <script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script> <script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script> <script src="src/js/calculator.js"></script>
<script src="src/js/calc-scientific.js"></script>
<script src="src/js/calc-converter.js"></script>
<script src="src/js/calc-satisfactory.js"></script>
<script src="src/js/calc-factorio.js"></script>
<script src="src/js/calc-stationeers.js"></script>
<script src="src/js/timer.js"></script> <script src="src/js/timer.js"></script>
<script src="src/js/image-ref.js"></script> <script src="src/js/image-ref.js"></script>
<script src="src/js/bookmark-import.js"></script> <script src="src/js/bookmark-import.js"></script>
+323 -5
View File
@@ -68,6 +68,19 @@
--board-hover-border: rgba(179,89,255,0.18); --board-hover-border: rgba(179,89,255,0.18);
--toggle-on-bg: rgba(214,92,255,0.22); --toggle-on-bg: rgba(214,92,255,0.22);
--logo-shadow: rgba(179,89,255,0.35); --logo-shadow: rgba(179,89,255,0.35);
--bg-solid-fallback: #0a060e;
}
/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */
@supports not (backdrop-filter: blur(1px)) {
.board,
.widget,
.settings-panel,
.dialog-box,
.theme-modal,
.search-bar {
background-color: var(--bg-solid-fallback, var(--bg-primary));
}
} }
/* ============================================ /* ============================================
@@ -91,6 +104,7 @@
--board-hover-border: rgba(179,89,255,0.18); --board-hover-border: rgba(179,89,255,0.18);
--toggle-on-bg: rgba(214,92,255,0.22); --toggle-on-bg: rgba(214,92,255,0.22);
--logo-shadow: rgba(179,89,255,0.35); --logo-shadow: rgba(179,89,255,0.35);
--bg-solid-fallback: #0a060e;
} }
[data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); } [data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); }
[data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); } [data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); }
@@ -116,6 +130,7 @@
--board-hover-border: rgba(212, 189, 138, 0.20); --board-hover-border: rgba(212, 189, 138, 0.20);
--toggle-on-bg: rgba(200,168,74,0.22); --toggle-on-bg: rgba(200,168,74,0.22);
--logo-shadow: rgba(212, 189, 138, 0.40); --logo-shadow: rgba(212, 189, 138, 0.40);
--bg-solid-fallback: #080c16;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
@@ -146,6 +161,7 @@
--board-hover-border: rgba(157, 92, 255, 0.22); --board-hover-border: rgba(157, 92, 255, 0.22);
--toggle-on-bg: rgba(224,128,48,0.22); --toggle-on-bg: rgba(224,128,48,0.22);
--logo-shadow: rgba(157, 92, 255, 0.45); --logo-shadow: rgba(157, 92, 255, 0.45);
--bg-solid-fallback: #08050f;
} }
[data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); } [data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); }
[data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); } [data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); }
@@ -172,6 +188,7 @@
--board-hover-border: rgba(46, 184, 184, 0.20); --board-hover-border: rgba(46, 184, 184, 0.20);
--toggle-on-bg: rgba(78,207,207,0.22); --toggle-on-bg: rgba(78,207,207,0.22);
--logo-shadow: rgba(46, 184, 184, 0.45); --logo-shadow: rgba(46, 184, 184, 0.45);
--bg-solid-fallback: #060a0a;
} }
[data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); } [data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); }
[data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); } [data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); }
@@ -197,6 +214,7 @@
--board-hover-border: rgba(125, 179, 255, 0.22); --board-hover-border: rgba(125, 179, 255, 0.22);
--toggle-on-bg: rgba(91,159,255,0.22); --toggle-on-bg: rgba(91,159,255,0.22);
--logo-shadow: rgba(125, 179, 255, 0.50); --logo-shadow: rgba(125, 179, 255, 0.50);
--bg-solid-fallback: #070a14;
} }
[data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; } [data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; }
[data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; } [data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; }
@@ -225,6 +243,7 @@
--board-hover-border: rgba(255, 140, 61, 0.22); --board-hover-border: rgba(255, 140, 61, 0.22);
--toggle-on-bg: rgba(240,124,48,0.22); --toggle-on-bg: rgba(240,124,48,0.22);
--logo-shadow: rgba(255, 140, 61, 0.45); --logo-shadow: rgba(255, 140, 61, 0.45);
--bg-solid-fallback: #0f0a08;
} }
[data-theme="sc-sunset"] .board { [data-theme="sc-sunset"] .board {
border-color: rgba(255, 140, 61, 0.15); border-color: rgba(255, 140, 61, 0.15);
@@ -253,6 +272,7 @@
--board-hover-border: rgba(50, 255, 106, 0.20); --board-hover-border: rgba(50, 255, 106, 0.20);
--toggle-on-bg: rgba(34,204,68,0.20); --toggle-on-bg: rgba(34,204,68,0.20);
--logo-shadow: rgba(50, 255, 106, 0.40); --logo-shadow: rgba(50, 255, 106, 0.40);
--bg-solid-fallback: #050805;
--danger: #ff4d4d; --danger: #ff4d4d;
} }
[data-theme="hellion-hud"] .board { [data-theme="hellion-hud"] .board {
@@ -287,6 +307,7 @@
--board-hover-border: rgba(30, 255, 142, 0.25); --board-hover-border: rgba(30, 255, 142, 0.25);
--toggle-on-bg: rgba(0,232,122,0.18); --toggle-on-bg: rgba(0,232,122,0.18);
--logo-shadow: rgba(30, 255, 142, 0.60); --logo-shadow: rgba(30, 255, 142, 0.60);
--bg-solid-fallback: #040705;
} }
[data-theme="hellion-energy"] .board { [data-theme="hellion-energy"] .board {
border-color: rgba(30, 255, 142, 0.15); border-color: rgba(30, 255, 142, 0.15);
@@ -322,6 +343,7 @@
--board-hover-border: rgba(0, 180, 216, 0.25); --board-hover-border: rgba(0, 180, 216, 0.25);
--toggle-on-bg: rgba(0, 180, 216, 0.20); --toggle-on-bg: rgba(0, 180, 216, 0.20);
--logo-shadow: rgba(0, 180, 216, 0.40); --logo-shadow: rgba(0, 180, 216, 0.40);
--bg-solid-fallback: #1a0f08;
} }
[data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; } [data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; }
[data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); } [data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
@@ -352,6 +374,7 @@
--board-hover-border: rgba(46, 196, 160, 0.22); --board-hover-border: rgba(46, 196, 160, 0.22);
--toggle-on-bg: rgba(46, 196, 160, 0.18); --toggle-on-bg: rgba(46, 196, 160, 0.18);
--logo-shadow: rgba(46, 196, 160, 0.50); --logo-shadow: rgba(46, 196, 160, 0.50);
--bg-solid-fallback: #020d0c;
} }
[data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; } [data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; }
[data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); } [data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); }
@@ -382,6 +405,7 @@
--board-hover-border: rgba(94, 194, 255, 0.25); --board-hover-border: rgba(94, 194, 255, 0.25);
--toggle-on-bg: rgba(94, 194, 255, 0.20); --toggle-on-bg: rgba(94, 194, 255, 0.20);
--logo-shadow: rgba(94, 194, 255, 0.45); --logo-shadow: rgba(94, 194, 255, 0.45);
--bg-solid-fallback: #0d0f12;
} }
[data-theme="hellion-stealth"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; } [data-theme="hellion-stealth"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; }
[data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); } [data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); }
@@ -562,12 +586,13 @@ html, body {
body.compact .bm-item { padding: var(--spacing-compact) 10px; } body.compact .bm-item { padding: var(--spacing-compact) 10px; }
.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; } .bm-favicon-local {
.bm-favicon-fallback { width: 16px; height: 16px; flex-shrink: 0;
width: 14px; height: 14px; flex-shrink: 0; border-radius: 3px;
background: var(--accent-dim); border-radius: 2px;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 8px; color: var(--accent); font-size: 9px; font-weight: 600;
color: #fff;
line-height: 1;
} }
.bm-text { flex: 1; min-width: 0; } .bm-text { flex: 1; min-width: 0; }
.bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; } .bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; }
@@ -1257,6 +1282,299 @@ body.show-desc .bm-desc { display: block; }
font-weight: 600; font-weight: 600;
} }
/* Calculator Tab System */
.calc-tab-bar {
display: flex;
background: rgba(0,0,0,0.2);
border-bottom: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
flex-shrink: 0;
}
.calc-tab-bar::-webkit-scrollbar {
display: none;
}
.calc-tab {
display: flex;
align-items: center;
gap: 3px;
padding: 6px 8px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--text-muted);
font-size: 11px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
white-space: nowrap;
transition: color 0.15s, border-color 0.15s;
flex-shrink: 0;
}
.calc-tab:hover {
color: var(--text-secondary);
}
.calc-tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
font-weight: 600;
}
.calc-tab-icon {
font-size: 12px;
}
.calc-tab-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calc-mode-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
/* Calculator Scientific Mode */
.calc-sci-buttons {
grid-template-columns: repeat(3, 1fr);
margin-bottom: 4px;
}
.calc-formula-helper {
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 4px;
}
.calc-formula-label {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.calc-formula-select {
width: 100%;
padding: 4px 6px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Rajdhani', sans-serif;
margin-bottom: 6px;
}
.calc-formula-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.calc-formula-row label {
font-size: 11px;
color: var(--text-secondary);
min-width: 50px;
}
.calc-formula-input {
flex: 1;
padding: 4px 6px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Rajdhani', sans-serif;
}
.calc-formula-result {
font-size: 14px;
color: var(--accent);
font-weight: 600;
font-family: 'Rajdhani', monospace;
text-align: right;
min-height: 20px;
padding: 2px 0;
}
/* Calculator Converter Mode */
.calc-conv-select {
width: 100%;
padding: 6px 8px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: 'Rajdhani', sans-serif;
}
.calc-conv-row {
display: flex;
gap: 6px;
}
.calc-conv-input {
flex: 1;
padding: 8px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 16px;
font-family: 'Rajdhani', monospace;
}
.calc-conv-input:read-only {
color: var(--accent);
font-weight: 600;
}
.calc-conv-unit {
width: 80px;
padding: 4px 6px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Rajdhani', sans-serif;
}
.calc-conv-swap {
align-self: center;
width: 36px;
height: 28px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--accent);
font-size: 16px;
cursor: pointer;
transition: all 0.15s;
}
.calc-conv-swap:hover {
background: var(--accent-dim);
}
.calc-conv-ref {
font-size: 11px;
color: var(--text-muted);
padding: 4px 0;
border-top: 1px solid var(--border);
}
/* Calculator Game Modes (shared) */
.calc-game-subtabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.calc-game-subtab {
flex: 1;
padding: 5px 4px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: all 0.15s;
}
.calc-game-subtab:hover {
color: var(--text-secondary);
}
.calc-game-subtab.active {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.calc-game-field {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.calc-game-field label {
font-size: 11px;
color: var(--text-secondary);
min-width: 90px;
flex-shrink: 0;
}
.calc-game-input {
flex: 1;
padding: 5px 8px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 13px;
font-family: 'Rajdhani', monospace;
}
.calc-game-output {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-top: 4px;
}
.calc-game-output span:first-child {
font-size: 11px;
color: var(--text-muted);
}
.calc-game-value {
font-size: 14px;
color: var(--accent);
font-weight: 600;
font-family: 'Rajdhani', monospace;
}
.calc-game-warning {
font-size: 10px;
color: var(--danger);
padding: 2px 0;
}
.calc-game-content {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Calculator Stationeers specifics */
.calc-game-hint {
font-size: 10px;
color: var(--text-muted);
font-style: italic;
margin-top: -4px;
text-align: right;
}
.calc-game-details {
border-top: 1px solid var(--border);
padding-top: 6px;
margin-top: 4px;
}
.calc-game-details summary {
font-size: 10px;
color: var(--text-muted);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.calc-game-table {
width: 100%;
font-size: 11px;
border-collapse: collapse;
margin-top: 4px;
}
.calc-game-table th {
text-align: left;
color: var(--text-muted);
font-weight: 600;
padding: 2px 6px;
border-bottom: 1px solid var(--border);
}
.calc-game-table td {
padding: 2px 6px;
color: var(--text-secondary);
}
.calc-game-table tr:nth-child(even) td {
background: rgba(0,0,0,0.1);
}
/* ============================================ /* ============================================
TIMER WIDGET TIMER WIDGET
============================================ */ ============================================ */
+2 -2
View File
@@ -105,7 +105,7 @@ async function checkBackupReminder() {
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : []; const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : []; const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : []; const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
const data = { version: '2.0.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets }; const data = { version: '2.1.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -132,7 +132,7 @@ function startClock() {
`${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`; `${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`;
} }
tick(); tick();
setInterval(tick, 1000); const clockInterval = setInterval(tick, 1000);
} }
// ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ---- // ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ----
+5 -14
View File
@@ -215,19 +215,11 @@ function createBmEl(bm) {
li.dataset.bmUrl = bm.url; li.dataset.bmUrl = bm.url;
li.draggable = true; li.draggable = true;
const favicon = document.createElement('img'); const favicon = document.createElement('div');
favicon.className = 'bm-favicon'; favicon.className = 'bm-favicon-local';
favicon.width = 14; favicon.textContent = bm.title.charAt(0).toUpperCase();
favicon.height = 14; const hue = (bm.title.charCodeAt(0) * 137) % 360;
favicon.src = getFaviconUrl(bm.url); favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`;
favicon.addEventListener('error', function() {
this.classList.add('hidden');
this.nextElementSibling.classList.remove('hidden');
});
const fallback = document.createElement('div');
fallback.className = 'bm-favicon-fallback hidden';
fallback.textContent = bm.title.charAt(0).toUpperCase();
const textDiv = document.createElement('div'); const textDiv = document.createElement('div');
textDiv.className = 'bm-text'; textDiv.className = 'bm-text';
@@ -247,7 +239,6 @@ function createBmEl(bm) {
deleteBtn.textContent = '✕'; deleteBtn.textContent = '✕';
li.appendChild(favicon); li.appendChild(favicon);
li.appendChild(fallback);
li.appendChild(textDiv); li.appendChild(textDiv);
li.appendChild(deleteBtn); li.appendChild(deleteBtn);
+344
View File
@@ -0,0 +1,344 @@
/* =============================================
HELLION NEWTAB — calc-converter.js
Unit-Converter Modus für Calculator Widget
============================================= */
(function() {
'use strict';
const CATEGORIES = {
length: {
titleKey: 'calculator.conv.cat.length',
baseUnit: 'm',
units: {
mm: { toBase: v => v / 1000, fromBase: v => v * 1000 },
cm: { toBase: v => v / 100, fromBase: v => v * 100 },
m: { toBase: v => v, fromBase: v => v },
km: { toBase: v => v * 1000, fromBase: v => v / 1000 },
in: { toBase: v => v * 0.0254, fromBase: v => v / 0.0254 },
ft: { toBase: v => v * 0.3048, fromBase: v => v / 0.3048 },
yd: { toBase: v => v * 0.9144, fromBase: v => v / 0.9144 },
mi: { toBase: v => v * 1609.344, fromBase: v => v / 1609.344 }
}
},
weight: {
titleKey: 'calculator.conv.cat.weight',
baseUnit: 'g',
units: {
mg: { toBase: v => v / 1000, fromBase: v => v * 1000 },
g: { toBase: v => v, fromBase: v => v },
kg: { toBase: v => v * 1000, fromBase: v => v / 1000 },
t: { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
oz: { toBase: v => v * 28.3495, fromBase: v => v / 28.3495 },
lb: { toBase: v => v * 453.592, fromBase: v => v / 453.592 }
}
},
temperature: {
titleKey: 'calculator.conv.cat.temperature',
baseUnit: null,
units: { '\u00B0C': null, '\u00B0F': null, 'K': null },
convert(value, from, to) {
if (from === to) return value;
const key = from + '_' + to;
const conversions = {
'\u00B0C_\u00B0F': v => (v * 9 / 5) + 32,
'\u00B0C_K': v => v + 273.15,
'\u00B0F_\u00B0C': v => (v - 32) * 5 / 9,
'\u00B0F_K': v => (v - 32) * 5 / 9 + 273.15,
'K_\u00B0C': v => v - 273.15,
'K_\u00B0F': v => (v - 273.15) * 9 / 5 + 32
};
const fn = conversions[key];
return fn ? fn(value) : null;
}
},
volume: {
titleKey: 'calculator.conv.cat.volume',
baseUnit: 'ml',
units: {
ml: { toBase: v => v, fromBase: v => v },
L: { toBase: v => v * 1000, fromBase: v => v / 1000 },
'm\u00B3':{ toBase: v => v * 1000000, fromBase: v => v / 1000000 },
'gal(US)':{ toBase: v => v * 3785.41, fromBase: v => v / 3785.41 },
'gal(UK)':{ toBase: v => v * 4546.09, fromBase: v => v / 4546.09 },
'ft\u00B3':{ toBase: v => v * 28316.8, fromBase: v => v / 28316.8 }
}
},
speed: {
titleKey: 'calculator.conv.cat.speed',
baseUnit: 'm/s',
units: {
'm/s': { toBase: v => v, fromBase: v => v },
'km/h': { toBase: v => v / 3.6, fromBase: v => v * 3.6 },
'mph': { toBase: v => v * 0.44704, fromBase: v => v / 0.44704 },
'kn': { toBase: v => v * 0.514444, fromBase: v => v / 0.514444 }
}
},
area: {
titleKey: 'calculator.conv.cat.area',
baseUnit: 'm\u00B2',
units: {
'mm\u00B2': { toBase: v => v / 1000000, fromBase: v => v * 1000000 },
'cm\u00B2': { toBase: v => v / 10000, fromBase: v => v * 10000 },
'm\u00B2': { toBase: v => v, fromBase: v => v },
'km\u00B2': { toBase: v => v * 1000000, fromBase: v => v / 1000000 },
'ha': { toBase: v => v * 10000, fromBase: v => v / 10000 },
'acre': { toBase: v => v * 4046.86, fromBase: v => v / 4046.86 },
'ft\u00B2': { toBase: v => v * 0.092903, fromBase: v => v / 0.092903 },
'in\u00B2': { toBase: v => v * 0.00064516, fromBase: v => v / 0.00064516 }
}
}
};
const CATEGORY_ORDER = ['length', 'weight', 'temperature', 'volume', 'speed', 'area'];
let _currentCategory = 'length';
let _fromUnit = 'cm';
let _toUnit = 'in';
let _fromInput = null;
let _toInput = null;
let _refEl = null;
/**
* Converts a value from one unit to another within the current category.
* @param {number} value
* @param {string} from
* @param {string} to
* @returns {number|null}
*/
function convert(value, from, to) {
const cat = CATEGORIES[_currentCategory];
if (!cat) return null;
if (cat.convert) return cat.convert(value, from, to);
const fromDef = cat.units[from];
const toDef = cat.units[to];
if (!fromDef || !toDef) return null;
const base = fromDef.toBase(value);
return toDef.fromBase(base);
}
/**
* Recalculates the output field and reference lines based on current input.
*/
function recalc() {
if (!_fromInput || !_toInput) return;
const val = parseFloat(_fromInput.value);
if (isNaN(val)) {
_toInput.value = '';
updateReference();
return;
}
const result = convert(val, _fromUnit, _toUnit);
if (result === null) {
_toInput.value = '';
} else {
_toInput.value = Calculator._formatResult(result);
}
updateReference();
}
/**
* Updates the reference conversion lines below the inputs.
*/
function updateReference() {
if (!_refEl) return;
_refEl.textContent = '';
const r1 = convert(1, _fromUnit, _toUnit);
const r2 = convert(1, _toUnit, _fromUnit);
if (r1 !== null) {
const line1 = document.createElement('div');
line1.textContent = '1 ' + _fromUnit + ' = ' + Calculator._formatResult(r1) + ' ' + _toUnit;
_refEl.appendChild(line1);
}
if (r2 !== null) {
const line2 = document.createElement('div');
line2.textContent = '1 ' + _toUnit + ' = ' + Calculator._formatResult(r2) + ' ' + _fromUnit;
_refEl.appendChild(line2);
}
}
/**
* Populates a unit <select> element with options for the current category.
* @param {HTMLSelectElement} selectEl
* @param {string} selectedUnit
*/
function populateUnitSelect(selectEl, selectedUnit) {
while (selectEl.firstChild) {
selectEl.removeChild(selectEl.firstChild);
}
const cat = CATEGORIES[_currentCategory];
if (!cat) return;
const units = Object.keys(cat.units);
units.forEach(unit => {
const opt = document.createElement('option');
opt.value = unit;
opt.textContent = unit;
if (unit === selectedUnit) opt.selected = true;
selectEl.appendChild(opt);
});
}
/**
* Returns sensible default from/to units for a given category key.
* @param {string} catKey
* @returns {{ from: string, to: string }}
*/
function getDefaultUnits(catKey) {
const defaults = {
length: { from: 'cm', to: 'in' },
weight: { from: 'kg', to: 'lb' },
temperature: { from: '\u00B0C', to: '\u00B0F' },
volume: { from: 'L', to: 'gal(US)' },
speed: { from: 'km/h', to: 'mph' },
area: { from: 'm\u00B2', to: 'ft\u00B2' }
};
return defaults[catKey] || { from: Object.keys(CATEGORIES[catKey].units)[0], to: Object.keys(CATEGORIES[catKey].units)[1] };
}
/**
* Loads persisted converter state from storage.
*/
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.converter) {
const s = data.calculator.converter;
if (s.lastCategory && CATEGORIES[s.lastCategory]) _currentCategory = s.lastCategory;
if (s.fromUnit) _fromUnit = s.fromUnit;
if (s.toUnit) _toUnit = s.toUnit;
}
}
/**
* Persists current converter state to storage (read-before-write).
*/
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.converter = {
lastCategory: _currentCategory,
fromUnit: _fromUnit,
toUnit: _toUnit
};
await Store.set(Calculator.STORAGE_KEY, data);
}
/**
* Builds the converter UI and appends it to the widget body element.
* @param {HTMLElement} bodyEl
*/
function buildUI(bodyEl) {
const catSelect = document.createElement('select');
catSelect.className = 'calc-conv-select';
CATEGORY_ORDER.forEach(catKey => {
const opt = document.createElement('option');
opt.value = catKey;
opt.textContent = t(CATEGORIES[catKey].titleKey);
if (catKey === _currentCategory) opt.selected = true;
catSelect.appendChild(opt);
});
const fromRow = document.createElement('div');
fromRow.className = 'calc-conv-row';
_fromInput = document.createElement('input');
_fromInput.type = 'number';
_fromInput.className = 'calc-conv-input';
_fromInput.placeholder = '0';
_fromInput.step = 'any';
const fromSelect = document.createElement('select');
fromSelect.className = 'calc-conv-unit';
populateUnitSelect(fromSelect, _fromUnit);
fromRow.append(_fromInput, fromSelect);
const swapBtn = document.createElement('button');
swapBtn.type = 'button';
swapBtn.className = 'calc-conv-swap';
swapBtn.textContent = '\u21C5';
swapBtn.title = t('calculator.conv.swap');
const toRow = document.createElement('div');
toRow.className = 'calc-conv-row';
_toInput = document.createElement('input');
_toInput.type = 'text';
_toInput.className = 'calc-conv-input';
_toInput.readOnly = true;
_toInput.placeholder = '0';
const toSelect = document.createElement('select');
toSelect.className = 'calc-conv-unit';
populateUnitSelect(toSelect, _toUnit);
toRow.append(_toInput, toSelect);
_refEl = document.createElement('div');
_refEl.className = 'calc-conv-ref';
_fromInput.addEventListener('input', () => recalc());
fromSelect.addEventListener('change', () => {
_fromUnit = fromSelect.value;
recalc();
saveState();
});
toSelect.addEventListener('change', () => {
_toUnit = toSelect.value;
recalc();
saveState();
});
swapBtn.addEventListener('click', () => {
const tmpUnit = _fromUnit;
_fromUnit = _toUnit;
_toUnit = tmpUnit;
populateUnitSelect(fromSelect, _fromUnit);
populateUnitSelect(toSelect, _toUnit);
const currentVal = _toInput.value;
if (currentVal) {
_fromInput.value = currentVal;
}
recalc();
saveState();
});
catSelect.addEventListener('change', () => {
_currentCategory = catSelect.value;
const defaults = getDefaultUnits(_currentCategory);
_fromUnit = defaults.from;
_toUnit = defaults.to;
populateUnitSelect(fromSelect, _fromUnit);
populateUnitSelect(toSelect, _toUnit);
_fromInput.value = '';
_toInput.value = '';
updateReference();
saveState();
});
bodyEl.append(catSelect, fromRow, swapBtn, toRow, _refEl);
updateReference();
}
Calculator.registerMode('converter', {
label: '⚖️',
shortName: 'Unit',
titleKey: 'calculator.tab.converter',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
buildUI(bodyEl);
});
},
destroy() {
_fromInput = null;
_toInput = null;
_refEl = null;
saveState();
}
});
})();
+247
View File
@@ -0,0 +1,247 @@
/* =============================================
HELLION NEWTAB — calc-factorio.js
Factorio Calculator Modus
============================================= */
(function() {
'use strict';
const ASSEMBLERS = [
{ key: 'asm1', speed: 0.5 },
{ key: 'asm2', speed: 0.75 },
{ key: 'asm3', speed: 1.25 }
];
const BELTS = [
{ key: 'yellow', throughput: 15, perSide: 7.5 },
{ key: 'red', throughput: 30, perSide: 15 },
{ key: 'blue', throughput: 45, perSide: 22.5 }
];
const SUB_MODES = ['ratio', 'belt', 'machines'];
let _activeSubMode = 'ratio';
function createAssemblerSelect(selectedKey) {
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t('calculator.fac.assembler');
const select = document.createElement('select');
select.className = 'calc-game-input';
ASSEMBLERS.forEach(asm => {
const opt = document.createElement('option');
opt.value = asm.key;
opt.textContent = t('calculator.fac.asm.' + asm.key) + ' (' + asm.speed + 'x)';
if (asm.key === selectedKey) opt.selected = true;
select.appendChild(opt);
});
row.append(label, select);
return { row, select };
}
function createBeltSelect(selectedKey) {
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t('calculator.fac.belt');
const select = document.createElement('select');
select.className = 'calc-game-input';
BELTS.forEach(belt => {
const opt = document.createElement('option');
opt.value = belt.key;
opt.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + belt.throughput + '/s)';
if (belt.key === selectedKey) opt.selected = true;
select.appendChild(opt);
});
row.append(label, select);
return { row, select };
}
function getAssemblerSpeed(key) {
const asm = ASSEMBLERS.find(a => a.key === key);
return asm ? asm.speed : 1;
}
function getBelt(key) {
return BELTS.find(b => b.key === key) || BELTS[0];
}
function findSmallestBelt(throughput) {
for (const belt of BELTS) {
if (belt.throughput >= throughput) return belt;
}
return null;
}
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderRatio(container) {
const asmSelect = createAssemblerSelect('asm3');
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
const perSecOutput = createOutput('calculator.fac.items_per_sec');
const perMinOutput = createOutput('calculator.fac.items_per_min');
function calc() {
const speed = getAssemblerSpeed(asmSelect.select.value);
const output = parseFloat(outputField.input.value) || 0;
const time = parseFloat(timeField.input.value) || 1;
const perSec = output * speed / time;
const perMin = perSec * 60;
perSecOutput.value.textContent = Calculator._formatResult(perSec) + ' /s';
perMinOutput.value.textContent = Calculator._formatResult(perMin) + ' /min';
}
[outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
asmSelect.select.addEventListener('change', calc);
container.append(asmSelect.row, outputField.row, timeField.row, perSecOutput.row, perMinOutput.row);
calc();
}
function renderBelt(container) {
const beltSelect = createBeltSelect('yellow');
const consumeField = createField('calculator.fac.consume_per_sec', 1, { step: 0.1, min: 0.1 });
const machinesOutput = createOutput('calculator.fac.machines_per_belt');
const utilOutput = createOutput('calculator.fac.belt_utilization');
function calc() {
const belt = getBelt(beltSelect.select.value);
const consume = parseFloat(consumeField.input.value) || 1;
const machines = Math.floor(belt.throughput / consume);
const util = (consume * machines) / belt.throughput * 100;
machinesOutput.value.textContent = machines;
utilOutput.value.textContent = Calculator._formatResult(util) + '%';
}
consumeField.input.addEventListener('input', calc);
beltSelect.select.addEventListener('change', calc);
container.append(beltSelect.row, consumeField.row, machinesOutput.row, utilOutput.row);
calc();
}
function renderMachines(container) {
const asmSelect = createAssemblerSelect('asm3');
const targetField = createField('calculator.fac.target_output_sec', 10, { step: 0.1, min: 0.1 });
const outputField = createField('calculator.fac.recipe_output', 1, { step: 1, min: 1 });
const timeField = createField('calculator.fac.recipe_time', 1, { step: 0.1, min: 0.1 });
const machinesOutput = createOutput('calculator.fac.machines_needed');
const beltOutput = createOutput('calculator.fac.belt_needed');
function calc() {
const speed = getAssemblerSpeed(asmSelect.select.value);
const target = parseFloat(targetField.input.value) || 0;
const output = parseFloat(outputField.input.value) || 1;
const time = parseFloat(timeField.input.value) || 1;
const perMachine = output * speed / time;
const machines = perMachine > 0 ? Math.ceil(target / perMachine) : 0;
const totalThroughput = machines * perMachine;
const belt = findSmallestBelt(totalThroughput);
machinesOutput.value.textContent = machines;
if (belt) {
const util = (totalThroughput / belt.throughput) * 100;
beltOutput.value.textContent = t('calculator.fac.belt.' + belt.key) + ' (' + Calculator._formatResult(util) + '%)';
} else {
beltOutput.value.textContent = t('calculator.fac.exceeds_belt');
}
}
[targetField, outputField, timeField].forEach(f => f.input.addEventListener('input', calc));
asmSelect.select.addEventListener('change', calc);
container.append(asmSelect.row, targetField.row, outputField.row, timeField.row, machinesOutput.row, beltOutput.row);
calc();
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.factorio) {
const s = data.calculator.factorio;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.factorio = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'ratio': renderRatio(container); break;
case 'belt': renderBelt(container); break;
case 'machines': renderMachines(container); break;
}
}
Calculator.registerMode('factorio', {
label: '🏭',
shortName: 'FAC',
titleKey: 'calculator.tab.factorio',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.fac.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+184
View File
@@ -0,0 +1,184 @@
/* =============================================
HELLION NEWTAB — calc-satisfactory.js
Satisfactory Calculator Modus
============================================= */
(function() {
'use strict';
const POWER_EXPONENT = 1.321928;
const SUB_MODES = ['itemsPerMin', 'power', 'machines'];
let _activeSubMode = 'itemsPerMin';
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
if (opts.max !== undefined) input.max = opts.max;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderItemsPerMin(container) {
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const output = createOutput('calculator.sat.output_per_min');
function calc() {
const items = parseFloat(itemsField.input.value) || 0;
const time = parseFloat(timeField.input.value) || 1;
const clock = parseFloat(clockField.input.value) || 100;
const result = (items * 60) / time * (clock / 100);
output.value.textContent = Calculator._formatResult(result) + ' items/min';
}
[itemsField, timeField, clockField].forEach(f => f.input.addEventListener('input', calc));
container.append(itemsField.row, timeField.row, clockField.row, output.row);
calc();
}
function renderPower(container) {
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const powerOutput = createOutput('calculator.sat.power_usage');
const effOutput = createOutput('calculator.sat.efficiency');
function calc() {
const basePower = parseFloat(basePowerField.input.value) || 0;
const clock = parseFloat(clockField.input.value) || 100;
const ratio = clock / 100;
const power = basePower * Math.pow(ratio, POWER_EXPONENT);
const effPerItem = Math.pow(ratio, POWER_EXPONENT - 1);
powerOutput.value.textContent = Calculator._formatResult(power) + ' MW';
if (clock > 100) {
const overhead = (effPerItem - 1) * 100;
effOutput.value.textContent = '+' + Calculator._formatResult(overhead) + '% ' + t('calculator.sat.per_item');
effOutput.row.style.display = '';
} else {
effOutput.row.style.display = 'none';
}
}
[basePowerField, clockField].forEach(f => f.input.addEventListener('input', calc));
container.append(basePowerField.row, clockField.row, powerOutput.row, effOutput.row);
calc();
}
function renderMachines(container) {
const targetField = createField('calculator.sat.target_output', 60, { step: 1, min: 1 });
const itemsField = createField('calculator.sat.items_per_craft', 1, { step: 1, min: 1 });
const timeField = createField('calculator.sat.craft_time', 4, { step: 0.1, min: 0.1 });
const clockField = createField('calculator.sat.clock_speed', 100, { step: 1, min: 1, max: 250 });
const basePowerField = createField('calculator.sat.base_power', 30, { step: 1, min: 0.1 });
const machinesOutput = createOutput('calculator.sat.machines_needed');
const totalPowerOutput = createOutput('calculator.sat.total_power');
function calc() {
const target = parseFloat(targetField.input.value) || 0;
const items = parseFloat(itemsField.input.value) || 1;
const time = parseFloat(timeField.input.value) || 1;
const clock = parseFloat(clockField.input.value) || 100;
const basePower = parseFloat(basePowerField.input.value) || 0;
const ratio = clock / 100;
const itemsPerMin = (items * 60) / time * ratio;
const machines = itemsPerMin > 0 ? Math.ceil(target / itemsPerMin) : 0;
const totalPower = machines * basePower * Math.pow(ratio, POWER_EXPONENT);
machinesOutput.value.textContent = machines;
totalPowerOutput.value.textContent = Calculator._formatResult(totalPower) + ' MW';
}
[targetField, itemsField, timeField, clockField, basePowerField].forEach(f => f.input.addEventListener('input', calc));
container.append(targetField.row, itemsField.row, timeField.row, clockField.row, basePowerField.row, machinesOutput.row, totalPowerOutput.row);
calc();
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.satisfactory) {
const s = data.calculator.satisfactory;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.satisfactory = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'itemsPerMin': renderItemsPerMin(container); break;
case 'power': renderPower(container); break;
case 'machines': renderMachines(container); break;
}
}
Calculator.registerMode('satisfactory', {
label: '⚙️',
shortName: 'SAT',
titleKey: 'calculator.tab.satisfactory',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.sat.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+294
View File
@@ -0,0 +1,294 @@
/* =============================================
HELLION NEWTAB — calc-scientific.js
Scientific-Modus für Calculator Widget
============================================= */
(function() {
'use strict';
const FORMULAS = [
{
key: 'circle_area',
fields: [{ key: 'radius', default: '' }],
calc: (vals) => Math.PI * vals.radius * vals.radius
},
{
key: 'circle_circumference',
fields: [{ key: 'radius', default: '' }],
calc: (vals) => 2 * Math.PI * vals.radius
},
{
key: 'celsius_to_fahrenheit',
fields: [{ key: 'temp', default: '' }],
calc: (vals) => (vals.temp * 9 / 5) + 32
},
{
key: 'fahrenheit_to_celsius',
fields: [{ key: 'temp', default: '' }],
calc: (vals) => (vals.temp - 32) * 5 / 9
},
{
key: 'pythagoras',
fields: [{ key: 'a', default: '' }, { key: 'b', default: '' }],
calc: (vals) => Math.sqrt(vals.a * vals.a + vals.b * vals.b)
},
{
key: 'percentage',
fields: [{ key: 'value', default: '' }, { key: 'percent', default: '' }],
calc: (vals) => vals.value * vals.percent / 100
}
];
let _keyboardExtHandler = null;
function renderSciButtons(container) {
const grid = document.createElement('div');
grid.className = 'calc-buttons calc-sci-buttons';
const buttons = [
['√', 'sqrt', 'operator'],
['x²', 'square', 'operator'],
['xⁿ', 'power', 'operator'],
['π', 'pi', 'operator'],
['e', 'euler', 'operator'],
['±', 'negate', 'operator']
];
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', () => handleSciKey(value));
grid.appendChild(btn);
});
container.appendChild(grid);
}
function handleSciKey(key) {
switch (key) {
case 'sqrt':
if (!Calculator._currentExpr && Calculator._lastResult) {
Calculator._currentExpr = 'sqrt(' + Calculator._lastResult + ')';
Calculator._lastResult = '';
Calculator._updateDisplay();
break;
}
Calculator._currentExpr += 'sqrt(';
Calculator._updateDisplay();
break;
case 'square':
if (!Calculator._currentExpr && Calculator._lastResult) {
Calculator._currentExpr = Calculator._lastResult;
Calculator._lastResult = '';
}
Calculator._currentExpr += '^2';
Calculator._updateDisplay();
break;
case 'power':
Calculator._handleKey('^');
break;
case 'pi':
Calculator._currentExpr += '3.14159265359';
Calculator._updateDisplay();
break;
case 'euler':
Calculator._currentExpr += '2.71828182846';
Calculator._updateDisplay();
break;
case 'negate':
handleNegate();
break;
}
}
function handleNegate() {
const expr = Calculator._currentExpr;
if (!expr && Calculator._lastResult) {
const num = parseFloat(Calculator._lastResult);
if (!isNaN(num)) {
Calculator._currentExpr = String(-num);
Calculator._lastResult = '';
Calculator._updateDisplay();
}
return;
}
const match = expr.match(/(-?\d*\.?\d+)$/);
if (match) {
const num = parseFloat(match[1]);
const negated = String(-num);
Calculator._currentExpr = expr.slice(0, expr.length - match[1].length) + negated;
Calculator._updateDisplay();
}
}
function renderFormulaHelper(container) {
const wrapper = document.createElement('div');
wrapper.className = 'calc-formula-helper';
const label = document.createElement('div');
label.className = 'calc-formula-label';
label.textContent = t('calculator.sci.formulas');
const select = document.createElement('select');
select.className = 'calc-formula-select';
const emptyOpt = document.createElement('option');
emptyOpt.value = '';
emptyOpt.textContent = t('calculator.sci.select_formula');
select.appendChild(emptyOpt);
FORMULAS.forEach((f, i) => {
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = t('calculator.sci.formula.' + f.key);
select.appendChild(opt);
});
const inputsContainer = document.createElement('div');
inputsContainer.className = 'calc-formula-inputs';
const resultContainer = document.createElement('div');
resultContainer.className = 'calc-formula-result';
select.addEventListener('change', () => {
while (inputsContainer.firstChild) {
inputsContainer.removeChild(inputsContainer.firstChild);
}
resultContainer.textContent = '';
const idx = parseInt(select.value, 10);
if (isNaN(idx)) return;
const formula = FORMULAS[idx];
renderFormulaInputs(formula, inputsContainer, resultContainer);
});
wrapper.append(label, select, inputsContainer, resultContainer);
container.appendChild(wrapper);
}
function renderFormulaInputs(formula, inputsEl, resultEl) {
const inputs = {};
formula.fields.forEach(field => {
const row = document.createElement('div');
row.className = 'calc-formula-row';
const lbl = document.createElement('label');
lbl.textContent = t('calculator.sci.field.' + field.key);
const inp = document.createElement('input');
inp.type = 'number';
inp.className = 'calc-formula-input';
inp.placeholder = '0';
inp.step = 'any';
inputs[field.key] = inp;
inp.addEventListener('input', () => {
recalcFormula(formula, inputs, resultEl);
});
row.append(lbl, inp);
inputsEl.appendChild(row);
});
}
function recalcFormula(formula, inputs, resultEl) {
const vals = {};
let allValid = true;
for (const field of formula.fields) {
const v = parseFloat(inputs[field.key].value);
if (isNaN(v)) { allValid = false; break; }
vals[field.key] = v;
}
if (!allValid) {
resultEl.textContent = '';
return;
}
const result = formula.calc(vals);
if (result === null || !isFinite(result)) {
resultEl.textContent = t('calculator.error');
return;
}
resultEl.textContent = '= ' + Calculator._formatResult(result);
}
function bindSciKeyboard(widgetEl) {
_keyboardExtHandler = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.target.contentEditable === 'true') return;
if (e.key === 'p') {
handleSciKey('pi');
e.preventDefault();
e.stopPropagation();
} else if (e.key === '^') {
handleSciKey('power');
e.preventDefault();
e.stopPropagation();
}
};
widgetEl.addEventListener('keydown', _keyboardExtHandler);
}
Calculator.registerMode('scientific', {
label: '📐',
shortName: 'Sci',
titleKey: 'calculator.tab.scientific',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.flex = '1';
bodyEl.style.overflow = 'hidden';
const display = document.createElement('div');
display.className = 'calc-display';
const exprEl = document.createElement('div');
exprEl.className = 'calc-expression';
Calculator._displayExprEl = exprEl;
const resultEl = document.createElement('div');
resultEl.className = 'calc-result';
resultEl.textContent = Calculator._lastResult || '0';
Calculator._displayResultEl = resultEl;
display.append(exprEl, resultEl);
const sciSection = document.createElement('div');
renderSciButtons(sciSection);
const stdButtons = Calculator._createButtons();
const historyEl = Calculator._createHistoryPanel();
const formulaSection = document.createElement('div');
renderFormulaHelper(formulaSection);
bodyEl.append(display, sciSection, stdButtons, historyEl, formulaSection);
Calculator._updateDisplay();
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
if (entry) bindSciKeyboard(entry.el);
},
destroy() {
if (_keyboardExtHandler) {
const entry = WidgetManager._widgets.get(Calculator.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', _keyboardExtHandler);
}
_keyboardExtHandler = null;
}
Calculator._displayExprEl = null;
Calculator._displayResultEl = null;
}
});
})();
+361
View File
@@ -0,0 +1,361 @@
/* =============================================
HELLION NEWTAB — calc-stationeers.js
Stationeers Calculator Modus
============================================= */
(function() {
'use strict';
const R = 8314.46261815324;
const COMBUSTION_ENERGY = 563452;
const HEAT_CAP_PURE_FUEL = 61.9;
const HEAT_CAP_DELTA = 172.615;
const BATTERY_CAPACITY = 50000;
const HEAT_CAPS = [
{ gas: 'O\u2082', cp: 21.1 },
{ gas: 'H\u2082', cp: 20.4 },
{ gas: 'CO\u2082', cp: 28.2 },
{ gas: 'N\u2082', cp: 20.6 },
{ gas: 'H\u2082O', cp: 72.0 },
{ gas: 'N\u2082O', cp: 23.0 },
{ gas: 'Pollutant', cp: 24.8 }
];
const GAS_VARS = ['P', 'V', 'n', 'T'];
const SUB_MODES = ['gas', 'furnace', 'solar', 'atmo'];
let _activeSubMode = 'gas';
function createField(labelKey, defaultVal, opts) {
opts = opts || {};
const row = document.createElement('div');
row.className = 'calc-game-field';
const label = document.createElement('label');
label.textContent = t(labelKey);
const input = document.createElement('input');
input.type = 'number';
input.className = 'calc-game-input';
input.value = defaultVal;
if (opts.step) input.step = opts.step;
if (opts.min !== undefined) input.min = opts.min;
if (opts.max !== undefined) input.max = opts.max;
if (opts.disabled) input.disabled = true;
row.append(label, input);
return { row, input };
}
function createOutput(labelKey) {
const row = document.createElement('div');
row.className = 'calc-game-output';
const label = document.createElement('span');
label.textContent = t(labelKey);
const value = document.createElement('span');
value.className = 'calc-game-value';
row.append(label, value);
return { row, value };
}
function renderGas(container) {
const solveRow = document.createElement('div');
solveRow.className = 'calc-game-field';
const solveLabel = document.createElement('label');
solveLabel.textContent = t('calculator.sta.solve_for');
const solveSelect = document.createElement('select');
solveSelect.className = 'calc-game-input';
GAS_VARS.forEach(v => {
const opt = document.createElement('option');
opt.value = v;
opt.textContent = t('calculator.sta.var.' + v);
solveSelect.appendChild(opt);
});
solveRow.append(solveLabel, solveSelect);
container.appendChild(solveRow);
const fields = {};
const defaults = { P: 101.325, V: 1000, n: 1, T: 293.15 };
GAS_VARS.forEach(v => {
const f = createField(
'calculator.sta.var.' + v + '_label',
defaults[v],
{ step: 'any' }
);
fields[v] = f;
container.appendChild(f.row);
});
const tempHelper = document.createElement('div');
tempHelper.className = 'calc-game-hint';
container.appendChild(tempHelper);
const resultOutput = createOutput('calculator.sta.result');
container.appendChild(resultOutput.row);
function calc() {
const solveFor = solveSelect.value;
GAS_VARS.forEach(v => {
fields[v].input.disabled = (v === solveFor);
fields[v].input.style.opacity = (v === solveFor) ? '0.5' : '1';
});
const P_kPa = parseFloat(fields.P.input.value) || 0;
const P = P_kPa * 1000;
const V = parseFloat(fields.V.input.value) || 0;
const n = parseFloat(fields.n.input.value) || 0;
const T = parseFloat(fields.T.input.value) || 0;
let result = null;
let unit = '';
switch (solveFor) {
case 'P':
if (V > 0) { result = (n * R * T) / V; result /= 1000; unit = 'kPa'; }
break;
case 'V':
if (P > 0) { result = (n * R * T) / P; unit = 'L'; }
break;
case 'n':
if (R * T > 0) { result = (P * V) / (R * T); unit = 'mol'; }
break;
case 'T':
if (n * R > 0) { result = (P * V) / (n * R); unit = 'K'; }
break;
}
if (result !== null && isFinite(result)) {
fields[solveFor].input.value = Calculator._formatResult(result);
resultOutput.value.textContent = Calculator._formatResult(result) + ' ' + unit;
} else {
resultOutput.value.textContent = '-';
}
const tempVal = parseFloat(fields.T.input.value) || 0;
tempHelper.textContent = Calculator._formatResult(tempVal - 273.15) + ' \u00B0C';
}
GAS_VARS.forEach(v => {
fields[v].input.addEventListener('input', calc);
});
solveSelect.addEventListener('change', calc);
calc();
}
function renderFurnace(container) {
const fuelField = createField('calculator.sta.fuel_ratio', 0.5, { step: 0.01, min: 0, max: 1 });
const tempField = createField('calculator.sta.start_temp', 293.15, { step: 1, min: 0 });
const pressField = createField('calculator.sta.start_pressure', 101.325, { step: 0.1, min: 0 });
const tempOutput = createOutput('calculator.sta.temp_after');
const pressOutput = createOutput('calculator.sta.pressure_after');
const warningEl = document.createElement('div');
warningEl.className = 'calc-game-warning';
function calc() {
const fuel = parseFloat(fuelField.input.value) || 0;
const T_vor = parseFloat(tempField.input.value) || 293.15;
const P_vor = parseFloat(pressField.input.value) || 101.325;
warningEl.textContent = '';
if (fuel < 0.05) {
warningEl.textContent = t('calculator.sta.warn_low_fuel');
}
if (P_vor < 10) {
warningEl.textContent += (warningEl.textContent ? ' ' : '') + t('calculator.sta.warn_low_pressure');
}
const specificHeat = HEAT_CAP_PURE_FUEL;
const T_nach = (T_vor * specificHeat + fuel * COMBUSTION_ENERGY) / (specificHeat + fuel * HEAT_CAP_DELTA);
const P_nach = P_vor * T_nach * (1 + 5.7 * fuel) / T_vor;
tempOutput.value.textContent = Calculator._formatResult(T_nach) + ' K (' + Calculator._formatResult(T_nach - 273.15) + ' \u00B0C)';
pressOutput.value.textContent = Calculator._formatResult(P_nach) + ' kPa';
}
[fuelField, tempField, pressField].forEach(f => f.input.addEventListener('input', calc));
container.append(fuelField.row, tempField.row, pressField.row, warningEl, tempOutput.row, pressOutput.row);
calc();
}
function renderSolar(container) {
const panelField = createField('calculator.sta.panels', 12, { step: 1, min: 1 });
const wattField = createField('calculator.sta.watts_per_panel', 500, { step: 10, min: 1 });
const dayField = createField('calculator.sta.day_length', 600, { step: 1, min: 1 });
const nightField = createField('calculator.sta.night_length', 600, { step: 1, min: 1 });
const consumeField = createField('calculator.sta.consumption', 2000, { step: 10, min: 0 });
const genOutput = createOutput('calculator.sta.generation');
const surplusOutput = createOutput('calculator.sta.surplus');
const nightOutput = createOutput('calculator.sta.night_energy');
const battOutput = createOutput('calculator.sta.batteries_needed');
function calc() {
const panels = parseFloat(panelField.input.value) || 0;
const wpp = parseFloat(wattField.input.value) || 0;
const nightLen = parseFloat(nightField.input.value) || 0;
const consume = parseFloat(consumeField.input.value) || 0;
const generation = panels * wpp;
const surplus = generation - consume;
const nightEnergy = consume * nightLen;
const batteries = nightEnergy > 0 ? Math.ceil(nightEnergy / BATTERY_CAPACITY) : 0;
genOutput.value.textContent = Calculator._formatResult(generation) + ' W';
surplusOutput.value.textContent = Calculator._formatResult(surplus) + ' W';
if (surplus < 0) {
surplusOutput.value.style.color = 'var(--danger)';
} else {
surplusOutput.value.style.color = '';
}
nightOutput.value.textContent = Calculator._formatResult(nightEnergy) + ' Ws';
battOutput.value.textContent = batteries;
}
[panelField, wattField, dayField, nightField, consumeField].forEach(f => f.input.addEventListener('input', calc));
container.append(panelField.row, wattField.row, dayField.row, nightField.row, consumeField.row,
genOutput.row, surplusOutput.row, nightOutput.row, battOutput.row);
calc();
}
function renderAtmo(container) {
const targetField = createField('calculator.sta.target_temp', 293.15, { step: 1 });
const gas1Field = createField('calculator.sta.gas1_temp', 200, { step: 1 });
const gas2Field = createField('calculator.sta.gas2_temp', 400, { step: 1 });
const m1Output = createOutput('calculator.sta.mixer_input1');
const m2Output = createOutput('calculator.sta.mixer_input2');
function calc() {
const T0 = parseFloat(targetField.input.value) || 0;
const T1 = parseFloat(gas1Field.input.value) || 0;
const T2 = parseFloat(gas2Field.input.value) || 0;
const denom = Math.abs(T1 - T0) + Math.abs(T2 - T0);
if (denom === 0) {
m1Output.value.textContent = '50%';
m2Output.value.textContent = '50%';
return;
}
const M1 = Math.abs(T2 - T0) / denom;
const M2 = 1 - M1;
m1Output.value.textContent = Calculator._formatResult(M1 * 100) + '%';
m2Output.value.textContent = Calculator._formatResult(M2 * 100) + '%';
}
[targetField, gas1Field, gas2Field].forEach(f => f.input.addEventListener('input', calc));
container.append(targetField.row, gas1Field.row, gas2Field.row, m1Output.row, m2Output.row);
calc();
const details = document.createElement('details');
details.className = 'calc-game-details';
const summary = document.createElement('summary');
summary.textContent = t('calculator.sta.heat_cap_ref');
details.appendChild(summary);
const table = document.createElement('table');
table.className = 'calc-game-table';
const thead = document.createElement('thead');
const headerRow = document.createElement('tr');
const thGas = document.createElement('th');
thGas.textContent = t('calculator.sta.gas');
const thCp = document.createElement('th');
thCp.textContent = 'Cp (J/mol\u00B7K)';
headerRow.append(thGas, thCp);
thead.appendChild(headerRow);
const tbody = document.createElement('tbody');
HEAT_CAPS.forEach(entry => {
const tr = document.createElement('tr');
const tdGas = document.createElement('td');
tdGas.textContent = entry.gas;
const tdCp = document.createElement('td');
tdCp.textContent = entry.cp;
tr.append(tdGas, tdCp);
tbody.appendChild(tr);
});
table.append(thead, tbody);
details.appendChild(table);
container.appendChild(details);
}
async function loadState() {
const data = await Store.get(Calculator.STORAGE_KEY);
if (data && data.calculator && data.calculator.stationeers) {
const s = data.calculator.stationeers;
if (s.lastSubMode && SUB_MODES.includes(s.lastSubMode)) _activeSubMode = s.lastSubMode;
}
}
async function saveState() {
const data = await Store.get(Calculator.STORAGE_KEY) || {};
if (!data.calculator) data.calculator = {};
data.calculator.stationeers = { lastSubMode: _activeSubMode };
await Store.set(Calculator.STORAGE_KEY, data);
}
function renderSubMode(container) {
container.textContent = '';
switch (_activeSubMode) {
case 'gas': renderGas(container); break;
case 'furnace': renderFurnace(container); break;
case 'solar': renderSolar(container); break;
case 'atmo': renderAtmo(container); break;
}
}
Calculator.registerMode('stationeers', {
label: '\uD83D\uDE80',
shortName: 'STA',
titleKey: 'calculator.tab.stationeers',
render(bodyEl) {
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
bodyEl.style.gap = '8px';
loadState().then(() => {
const subContent = document.createElement('div');
subContent.className = 'calc-game-content';
const bar = document.createElement('div');
bar.className = 'calc-game-subtabs';
SUB_MODES.forEach(mode => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'calc-game-subtab' + (mode === _activeSubMode ? ' active' : '');
btn.textContent = t('calculator.sta.tab.' + mode);
btn.dataset.mode = mode;
btn.addEventListener('click', () => {
bar.querySelectorAll('.calc-game-subtab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
_activeSubMode = mode;
renderSubMode(subContent);
saveState();
});
bar.appendChild(btn);
});
bodyEl.append(bar, subContent);
renderSubMode(subContent);
});
},
destroy() {
saveState();
}
});
})();
+258 -58
View File
@@ -17,6 +17,22 @@ const Calculator = {
_displayExprEl: null, _displayExprEl: null,
_displayResultEl: null, _displayResultEl: null,
_keydownHandler: 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 ---- // ---- STORAGE ----
@@ -27,6 +43,9 @@ const Calculator = {
const data = await Store.get(this.STORAGE_KEY); const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator) { if (data && data.calculator) {
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : []; this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
if (data.calculator.activeMode) {
this._activeMode = data.calculator.activeMode;
}
} }
}, },
@@ -36,20 +55,19 @@ const Calculator = {
*/ */
async save() { async save() {
const data = await Store.get(this.STORAGE_KEY) || {}; const data = await Store.get(this.STORAGE_KEY) || {};
const notesState = Array.isArray(data.notes) ? data.notes : [];
// Widget-Position aus WidgetManager holen // Widget-Position aus WidgetManager holen
const widgetState = WidgetManager.getState(this.WIDGET_ID); const widgetState = WidgetManager.getState(this.WIDGET_ID);
const calcData = { if (!data.calculator) data.calculator = {};
x: widgetState ? widgetState.x : 400, data.calculator.x = widgetState ? widgetState.x : 400;
y: widgetState ? widgetState.y : 120, data.calculator.y = widgetState ? widgetState.y : 120;
width: widgetState ? widgetState.width : 280, data.calculator.width = widgetState ? widgetState.width : 280;
height: widgetState ? widgetState.height : 400, data.calculator.height = widgetState ? widgetState.height : 400;
open: this._isOpen, data.calculator.open = this._isOpen;
history: this._history.slice(0, this.MAX_HISTORY) data.calculator.activeMode = this._activeMode;
}; data.calculator.history = this._history.slice(0, this.MAX_HISTORY);
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData }); await Store.set(this.STORAGE_KEY, data);
}, },
// ---- WIDGET LIFECYCLE ---- // ---- WIDGET LIFECYCLE ----
@@ -113,8 +131,13 @@ const Calculator = {
* Wird aufgerufen wenn Widget geschlossen wird * Wird aufgerufen wenn Widget geschlossen wird
*/ */
async onClose() { async onClose() {
// Aktiven Modus aufräumen
const mode = this._modes.get(this._activeMode);
if (mode && mode.destroy) mode.destroy();
this._isOpen = false; this._isOpen = false;
this._unbindKeyboard(); this._unbindKeyboard();
this._tabBarEl = null;
this._displayExprEl = null; this._displayExprEl = null;
this._displayResultEl = null; this._displayResultEl = null;
await this.save(); await this.save();
@@ -123,14 +146,136 @@ const Calculator = {
// ---- UI RENDERING ---- // ---- UI RENDERING ----
/** /**
* Calculator-Body rendern (in Widget-Body einfuegen) * Calculator-Body rendern: Tab-Bar + aktiver Modus
* @param {HTMLElement} bodyEl * @param {HTMLElement} bodyEl
*/ */
renderBody(bodyEl) { renderBody(bodyEl) {
bodyEl.textContent = ''; 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.padding = '8px';
bodyEl.style.display = 'flex'; bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column'; bodyEl.style.flexDirection = 'column';
bodyEl.style.flex = '1';
bodyEl.style.overflow = 'hidden';
// Display // Display
const display = document.createElement('div'); const display = document.createElement('div');
@@ -297,7 +442,8 @@ const Calculator = {
case '+': case '+':
case '-': case '-':
case '*': case '*':
case '/': { case '/':
case '^': {
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen // Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
if (this._lastResult && this._currentExpr === '') { if (this._lastResult && this._currentExpr === '') {
this._currentExpr = this._lastResult; this._currentExpr = this._lastResult;
@@ -305,7 +451,7 @@ const Calculator = {
} }
// Doppelte Operatoren verhindern (letzten ersetzen) // Doppelte Operatoren verhindern (letzten ersetzen)
const last = this._currentExpr.slice(-1); const last = this._currentExpr.slice(-1);
if (/[+\-*/%]/.test(last)) { if (/[+\-*/%^]/.test(last)) {
this._currentExpr = this._currentExpr.slice(0, -1) + key; this._currentExpr = this._currentExpr.slice(0, -1) + key;
} else { } else {
this._currentExpr += key; this._currentExpr += key;
@@ -315,7 +461,7 @@ const Calculator = {
case '.': { case '.': {
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern // Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
const parts = this._currentExpr.split(/[+\-*/%()]/); const parts = this._currentExpr.split(/[+\-*/%()^]/);
const lastPart = parts[parts.length - 1]; const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.includes('.')) break; if (lastPart && lastPart.includes('.')) break;
this._currentExpr += key; this._currentExpr += key;
@@ -381,7 +527,7 @@ const Calculator = {
_evaluate(expr) { _evaluate(expr) {
try { try {
// Nur erlaubte Zeichen // Nur erlaubte Zeichen
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, ''); const sanitized = expr.replace(/[^0-9+\-*/.%()^a-z]/g, '');
if (!sanitized) return null; if (!sanitized) return null;
const tokens = this._tokenize(sanitized); const tokens = this._tokenize(sanitized);
@@ -405,6 +551,13 @@ const Calculator = {
while (i < expr.length) { while (i < expr.length) {
const ch = expr[i]; 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) // Zahl (inkl. Dezimal)
if (/[0-9.]/.test(ch)) { if (/[0-9.]/.test(ch)) {
let num = ''; let num = '';
@@ -443,6 +596,13 @@ const Calculator = {
continue; continue;
} }
// Potenz-Operator
if (ch === '^') {
tokens.push({ type: 'op', value: '^' });
i++;
continue;
}
// Klammern // Klammern
if (ch === '(' || ch === ')') { if (ch === '(' || ch === ')') {
tokens.push({ type: 'paren', value: ch }); tokens.push({ type: 'paren', value: ch });
@@ -450,6 +610,11 @@ const Calculator = {
continue; continue;
} }
// Unbekannte Buchstaben
if (/[a-z]/.test(ch)) {
return null;
}
// Unbekanntes Zeichen // Unbekanntes Zeichen
return null; return null;
} }
@@ -459,6 +624,7 @@ const Calculator = {
/** /**
* Rekursiver Descent Parser mit Operator-Precedence * Rekursiver Descent Parser mit Operator-Precedence
* Hierarchie: parseExpr (+/-) → parseTerm (*\/%) → parsePower (^) → parseFactor
* @param {Array} tokens * @param {Array} tokens
* @returns {number|null} * @returns {number|null}
*/ */
@@ -468,36 +634,32 @@ const Calculator = {
function peek() { return tokens[pos]; } function peek() { return tokens[pos]; }
function consume() { return tokens[pos++]; } function consume() { return tokens[pos++]; }
// Expression: Term (('+' | '-') Term)*
function parseExpr() { function parseExpr() {
let left = parseTerm(); let left = parseTerm();
if (left === null) return null; if (left === null) return null;
while (pos < tokens.length) { while (pos < tokens.length) {
const t = peek(); const tk = peek();
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break; if (!tk || tk.type !== 'op' || (tk.value !== '+' && tk.value !== '-')) break;
consume(); consume();
const right = parseTerm(); const right = parseTerm();
if (right === null) return null; if (right === null) return null;
left = t.value === '+' ? left + right : left - right; left = tk.value === '+' ? left + right : left - right;
} }
return left; return left;
} }
// Term: Factor (('*' | '/' | '%') Factor)*
function parseTerm() { function parseTerm() {
let left = parseFactor(); let left = parsePower();
if (left === null) return null; if (left === null) return null;
while (pos < tokens.length) { while (pos < tokens.length) {
const t = peek(); const tk = peek();
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break; if (!tk || tk.type !== 'op' || (tk.value !== '*' && tk.value !== '/' && tk.value !== '%')) break;
consume(); consume();
const right = parseFactor(); const right = parsePower();
if (right === null) return null; if (right === null) return null;
if (t.value === '*') { if (tk.value === '*') {
left = left * right; left = left * right;
} else if (t.value === '/') { } else if (tk.value === '/') {
if (right === 0) return null; if (right === 0) return null;
left = left / right; left = left / right;
} else { } else {
@@ -507,17 +669,51 @@ const Calculator = {
return left; return left;
} }
// Factor: Number | '(' Expression ')' // Power: Factor ('^' Power)? — rechts-assoziativ via Rekursion
function parseFactor() { function parsePower() {
const t = peek(); let base = parseFactor();
if (!t) return null; if (base === null) return null;
const tk = peek();
if (t.type === 'number') { if (tk && tk.type === 'op' && tk.value === '^') {
consume(); consume();
return t.value; const exp = parsePower(); // Rechts-assoziativ!
if (exp === null) return null;
return Math.pow(base, exp);
}
return base;
} }
if (t.type === 'paren' && t.value === '(') { // 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(); consume();
const val = parseExpr(); const val = parseExpr();
if (val === null) return null; if (val === null) return null;
@@ -562,7 +758,8 @@ const Calculator = {
_formatExpression(expr) { _formatExpression(expr) {
return expr return expr
.replace(/\*/g, '\u00D7') .replace(/\*/g, '\u00D7')
.replace(/\//g, '\u00F7'); .replace(/\//g, '\u00F7')
.replace(/sqrt\(/g, '\u221A(');
}, },
// ---- DISPLAY ---- // ---- DISPLAY ----
@@ -683,47 +880,50 @@ const Calculator = {
async init() { async init() {
await this.load(); 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 // Wenn Calculator beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY); const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator && data.calculator.open) { if (data && data.calculator && data.calculator.open) {
await this.open(); await this.open();
} }
// Close-Event abfangen: WidgetManager.close() ueberschreiben // Widget-Lifecycle-Events
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this; const self = this;
WidgetManager.close = function(id) { WidgetManager.on('widget:close', (e) => {
origClose(id); if (e.detail.id === self.WIDGET_ID) {
if (id === self.WIDGET_ID) {
self.onClose(); self.onClose();
} }
}; });
// Minimize-Event abfangen WidgetManager.on('widget:minimize', (e) => {
const origMinimize = WidgetManager.minimize.bind(WidgetManager); if (e.detail.id === self.WIDGET_ID) {
WidgetManager.minimize = async function(id) {
await origMinimize(id);
if (id === self.WIDGET_ID) {
self._isOpen = false; self._isOpen = false;
await self.save(); self.save();
} }
}; });
// Open-Event abfangen WidgetManager.on('widget:open', (e) => {
const origOpen = WidgetManager.openWidget.bind(WidgetManager); if (e.detail.id === self.WIDGET_ID) {
WidgetManager.openWidget = async function(id) {
await origOpen(id);
if (id === self.WIDGET_ID) {
self._isOpen = true; self._isOpen = true;
// Body neu rendern (war durch minimize entfernt)
const body = WidgetManager.getBody(self.WIDGET_ID); const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) { if (body && body.children.length === 0) {
self.renderBody(body); self.renderBody(body);
} }
const entry = WidgetManager._widgets.get(self.WIDGET_ID); const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el); if (entry) self._bindKeyboard(entry.el);
await self.save(); self.save();
} }
}; });
} }
}; };
+50 -21
View File
@@ -9,11 +9,26 @@ function initDataButtons() {
const jsonInput = document.getElementById('jsonImportInput'); const jsonInput = document.getElementById('jsonImportInput');
if (!btnExport || !btnImport) return; if (!btnExport || !btnImport) return;
/**
* Prueft ob eine URL ein sicheres Protokoll hat.
* Blockiert javascript:, data:, vbscript: etc.
* @param {string} url
* @returns {boolean}
*/
function isSafeUrl(url) {
try {
const u = new URL(url);
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
} catch {
return false;
}
}
// Export (inkl. Notes) // Export (inkl. Notes)
btnExport.addEventListener('click', async () => { btnExport.addEventListener('click', async () => {
const widgetData = await Store.get('widgetStates'); const widgetData = await Store.get('widgetStates');
const data = { const data = {
version: '2.0.0', version: '2.1.0',
exported: new Date().toISOString(), exported: new Date().toISOString(),
boards, boards,
settings, settings,
@@ -38,18 +53,21 @@ function initDataButtons() {
try { try {
const data = JSON.parse(await file.text()); const data = JSON.parse(await file.text());
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format')); if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
const validBoards = data.boards.filter(b => { const validBoards = data.boards
if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false; .filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
b.id = b.id || uid(); .map(b => ({
b.blurred = !!b.blurred; id: b.id || uid(),
b.bookmarks = b.bookmarks.filter(bm => { title: String(b.title).slice(0, 100),
if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false; blurred: !!b.blurred,
bm.id = bm.id || uid(); bookmarks: b.bookmarks
bm.desc = bm.desc || ''; .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url))
return true; .map(bm => ({
}); id: bm.id || uid(),
return true; title: String(bm.title).slice(0, 200),
}); url: bm.url,
desc: String(bm.desc || '').slice(0, 500)
}))
}));
if (validBoards.length === 0) throw new Error(t('data.no_boards')); if (validBoards.length === 0) throw new Error(t('data.no_boards'));
const ok = await HellionDialog.confirm( const ok = await HellionDialog.confirm(
t('data.import_confirm', { count: validBoards.length }), t('data.import_confirm', { count: validBoards.length }),
@@ -65,18 +83,26 @@ function initDataButtons() {
const existingWidgets = await Store.get('widgetStates') || {}; const existingWidgets = await Store.get('widgetStates') || {};
if (Array.isArray(data.notes) && data.notes.length > 0) { if (Array.isArray(data.notes) && data.notes.length > 0) {
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : []; const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
const importNotes = data.notes.filter(n => { const importNotes = data.notes
if (!n || !n.id || !n.template) return false; .filter(n => n && n.id && n.template)
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : []; .map(n => ({
return true; id: n.id,
}); template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
title: String(n.title || '').slice(0, 200),
content: String(n.content || '').slice(0, 5000),
x: typeof n.x === 'number' ? n.x : 120,
y: typeof n.y === 'number' ? n.y : 80,
width: typeof n.width === 'number' ? n.width : 280,
height: typeof n.height === 'number' ? n.height : 220,
open: n.open !== false,
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
}));
// Limit beachten // Limit beachten
const spaceLeft = Notes.MAX_NOTES - existingNotes.length; const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
const toImport = importNotes.slice(0, spaceLeft); const toImport = importNotes.slice(0, spaceLeft);
if (toImport.length > 0) { if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport]; const merged = [...existingNotes, ...toImport];
existingWidgets.notes = merged; existingWidgets.notes = merged;
Notes._notes = merged;
notesImported = toImport.length; notesImported = toImport.length;
} }
} }
@@ -90,7 +116,6 @@ function initDataButtons() {
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] }; existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
} }
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY); existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
Calculator._history = existingWidgets.calculator.history;
calcImported = true; calcImported = true;
} }
} }
@@ -104,7 +129,6 @@ function initDataButtons() {
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] }; existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
} }
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS); existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
Timer._presets = existingWidgets.timer.presets;
timerImported = true; timerImported = true;
} }
} }
@@ -112,6 +136,11 @@ function initDataButtons() {
// Gemeinsam speichern // Gemeinsam speichern
await Store.set('widgetStates', existingWidgets); await Store.set('widgetStates', existingWidgets);
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
if (notesImported > 0) await Notes.init();
if (calcImported) await Calculator.load();
if (timerImported) await Timer.load();
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : ''; const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
const calcMsg = calcImported ? t('data.calc_suffix') : ''; const calcMsg = calcImported ? t('data.calc_suffix') : '';
const timerMsg = timerImported ? t('data.timer_suffix') : ''; const timerMsg = timerImported ? t('data.timer_suffix') : '';
+245 -1
View File
@@ -10,6 +10,7 @@ const STRINGS = {
'dialog.ok': 'OK', 'dialog.ok': 'OK',
'dialog.confirm_title': 'Bestätigung', 'dialog.confirm_title': 'Bestätigung',
'dialog.cancel': 'Abbrechen', 'dialog.cancel': 'Abbrechen',
'dialog.close': 'Schließen',
// Boards // Boards
'boards.empty_state_pre': 'Noch keine Boards. Klicke auf ', 'boards.empty_state_pre': 'Noch keine Boards. Klicke auf ',
@@ -56,6 +57,7 @@ const STRINGS = {
'onboarding.s5.text': 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten löschst, gehen sie verloren! Sichere regelmäßig über Settings → Data → Export. Wir erinnern dich alle 7 Tage daran.', 'onboarding.s5.text': 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten löschst, gehen sie verloren! Sichere regelmäßig über Settings → Data → Export. Wir erinnern dich alle 7 Tage daran.',
'onboarding.s6.title': 'Gaming Starter Board', 'onboarding.s6.title': 'Gaming Starter Board',
'onboarding.s6.text': 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit nützlichen Community-Links anlegen.', 'onboarding.s6.text': 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit nützlichen Community-Links anlegen.',
'onboarding.tradecenter_desc': 'Trade Center für Star Citizen',
'onboarding.s7.title': 'Bereit!', 'onboarding.s7.title': 'Bereit!',
'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!', 'onboarding.s7.text': 'Erstelle dein erstes Board mit „+ Board" oder importiere deine Browser-Lesezeichen über den Import-Button im Header. Viel Spaß!',
@@ -81,6 +83,110 @@ const STRINGS = {
'calculator.title': 'Taschenrechner', 'calculator.title': 'Taschenrechner',
'calculator.history': 'History', 'calculator.history': 'History',
'calculator.error': 'Fehler', 'calculator.error': 'Fehler',
'calculator.tab.standard': 'Standard',
'calculator.tab.scientific': 'Wissenschaftlich',
'calculator.sci.formulas': 'Formel-Helfer',
'calculator.sci.select_formula': 'Formel wählen…',
'calculator.sci.formula.circle_area': 'Kreisfläche (π×r²)',
'calculator.sci.formula.circle_circumference':'Kreisumfang (2πr)',
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))',
'calculator.sci.formula.percentage': 'Prozentwert',
'calculator.sci.field.radius': 'Radius',
'calculator.sci.field.temp': 'Temperatur',
'calculator.sci.field.a': 'Seite a',
'calculator.sci.field.b': 'Seite b',
'calculator.sci.field.value': 'Wert',
'calculator.sci.field.percent': 'Prozent',
'calculator.tab.converter': 'Umrechner',
'calculator.conv.swap': 'Einheiten tauschen',
'calculator.conv.cat.length': 'Länge',
'calculator.conv.cat.weight': 'Gewicht',
'calculator.conv.cat.temperature': 'Temperatur',
'calculator.conv.cat.volume': 'Volumen',
'calculator.conv.cat.speed': 'Geschwindigkeit',
'calculator.conv.cat.area': 'Fläche',
'calculator.tab.satisfactory': 'Satisfactory',
'calculator.sat.tab.itemsPerMin': 'Items/Min',
'calculator.sat.tab.power': 'Strom',
'calculator.sat.tab.machines': 'Maschinen',
'calculator.sat.items_per_craft': 'Items/Craft',
'calculator.sat.craft_time': 'Craftzeit (s)',
'calculator.sat.clock_speed': 'Taktrate (%)',
'calculator.sat.base_power': 'Grundleistung (MW)',
'calculator.sat.target_output': 'Ziel Output/Min',
'calculator.sat.output_per_min': 'Output',
'calculator.sat.power_usage': 'Stromverbrauch',
'calculator.sat.efficiency': 'Effizienz',
'calculator.sat.per_item': 'pro Item',
'calculator.sat.machines_needed': 'Maschinen benötigt',
'calculator.sat.total_power': 'Gesamtleistung',
// Factorio Calculator
'calculator.tab.factorio': 'Factorio',
'calculator.fac.tab.ratio': 'Ratio',
'calculator.fac.tab.belt': 'Belt',
'calculator.fac.tab.machines': 'Maschinen',
'calculator.fac.assembler': 'Assembler',
'calculator.fac.asm.asm1': 'Assembler 1',
'calculator.fac.asm.asm2': 'Assembler 2',
'calculator.fac.asm.asm3': 'Assembler 3',
'calculator.fac.belt': 'Belt-Typ',
'calculator.fac.belt.yellow': 'Gelb',
'calculator.fac.belt.red': 'Rot',
'calculator.fac.belt.blue': 'Blau',
'calculator.fac.recipe_output': 'Rezept-Output',
'calculator.fac.recipe_time': 'Rezeptzeit (s)',
'calculator.fac.consume_per_sec': 'Verbrauch/s',
'calculator.fac.target_output_sec': 'Ziel Output/s',
'calculator.fac.items_per_sec': 'Items/s',
'calculator.fac.items_per_min': 'Items/min',
'calculator.fac.machines_per_belt': 'Maschinen/Belt',
'calculator.fac.belt_utilization': 'Belt-Auslastung',
'calculator.fac.machines_needed': 'Maschinen benötigt',
'calculator.fac.belt_needed': 'Belt benötigt',
'calculator.fac.exceeds_belt': 'Übersteigt max. Belt',
// Stationeers Calculator
'calculator.tab.stationeers': 'Stationeers',
'calculator.sta.tab.gas': 'Gas',
'calculator.sta.tab.furnace': 'Ofen',
'calculator.sta.tab.solar': 'Solar',
'calculator.sta.tab.atmo': 'Atmo',
'calculator.sta.solve_for': 'Gesucht',
'calculator.sta.var.P': 'Druck (P)',
'calculator.sta.var.V': 'Volumen (V)',
'calculator.sta.var.n': 'Stoffmenge (n)',
'calculator.sta.var.T': 'Temperatur (T)',
'calculator.sta.var.P_label': 'Druck (kPa)',
'calculator.sta.var.V_label': 'Volumen (L)',
'calculator.sta.var.n_label': 'Stoffmenge (mol)',
'calculator.sta.var.T_label': 'Temperatur (K)',
'calculator.sta.result': 'Ergebnis',
'calculator.sta.fuel_ratio': 'Fuel-Anteil (0-1)',
'calculator.sta.start_temp': 'Start-Temperatur (K)',
'calculator.sta.start_pressure': 'Start-Druck (kPa)',
'calculator.sta.temp_after': 'T nach Zündung',
'calculator.sta.pressure_after': 'P nach Zündung',
'calculator.sta.warn_low_fuel': '\u26A0 Fuel unter 5%',
'calculator.sta.warn_low_pressure': '\u26A0 Druck unter 10 kPa',
'calculator.sta.panels': 'Anzahl Panels',
'calculator.sta.watts_per_panel': 'Watt/Panel',
'calculator.sta.day_length': 'Taglänge (s)',
'calculator.sta.night_length': 'Nachtlänge (s)',
'calculator.sta.consumption': 'Verbrauch (W)',
'calculator.sta.generation': 'Erzeugung',
'calculator.sta.surplus': 'Überschuss',
'calculator.sta.night_energy': 'Nacht-Energie',
'calculator.sta.batteries_needed': 'Batterien benötigt',
'calculator.sta.target_temp': 'Ziel-Temperatur (K)',
'calculator.sta.gas1_temp': 'Gas 1 Temperatur (K)',
'calculator.sta.gas2_temp': 'Gas 2 Temperatur (K)',
'calculator.sta.mixer_input1': 'Mixer Input 1',
'calculator.sta.mixer_input2': 'Mixer Input 2',
'calculator.sta.heat_cap_ref': 'Wärmekapazitäten (Referenz)',
'calculator.sta.gas': 'Gas',
// Timer // Timer
'timer.title': 'Timer', 'timer.title': 'Timer',
@@ -202,6 +308,13 @@ const STRINGS = {
'header.theme': 'Darstellung', 'header.theme': 'Darstellung',
'header.settings': 'Einstellungen', 'header.settings': 'Einstellungen',
// Header Tooltips
'header.import_title': 'Bookmarks importieren (HTML)',
'header.board_title': 'Neues Board hinzufügen',
'header.note_title': 'Schnellnotiz',
'header.theme_title': 'Darstellung & Theme',
'header.settings_title': 'Einstellungen',
// Settings-Panel Überschrift // Settings-Panel Überschrift
'settings.title': 'Einstellungen', 'settings.title': 'Einstellungen',
@@ -255,6 +368,13 @@ const STRINGS = {
'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden', 'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden',
'settings.search_engine_toggle': 'Suchmaschine wechseln', 'settings.search_engine_toggle': 'Suchmaschine wechseln',
// Settings Buttons + Validierung
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.',
'settings.bg_invalid_url.title': 'Ungültige URL',
// Modals // Modals
'modal.new_board': 'Neues Board', 'modal.new_board': 'Neues Board',
'modal.board_name': 'Board-Name...', 'modal.board_name': 'Board-Name...',
@@ -285,6 +405,7 @@ const STRINGS = {
// Suche // Suche
'search.placeholder': 'Im Web suchen…', 'search.placeholder': 'Im Web suchen…',
'search.submit_title': 'Suchen',
// Widget-Toolbar Tooltips // Widget-Toolbar Tooltips
'toolbar.note': 'Note erstellen', 'toolbar.note': 'Note erstellen',
@@ -301,6 +422,7 @@ const STRINGS = {
'dialog.ok': 'OK', 'dialog.ok': 'OK',
'dialog.confirm_title': 'Confirmation', 'dialog.confirm_title': 'Confirmation',
'dialog.cancel': 'Cancel', 'dialog.cancel': 'Cancel',
'dialog.close': 'Close',
// Boards // Boards
'boards.empty_state_pre': 'No boards yet. Click ', 'boards.empty_state_pre': 'No boards yet. Click ',
@@ -347,6 +469,7 @@ const STRINGS = {
'onboarding.s5.text': 'Your data is stored locally in the browser. If you clear browser data, it\'s gone! Back up regularly via Settings → Data → Export. We\'ll remind you every 7 days.', 'onboarding.s5.text': 'Your data is stored locally in the browser. If you clear browser data, it\'s gone! Back up regularly via Settings → Data → Export. We\'ll remind you every 7 days.',
'onboarding.s6.title': 'Gaming Starter Board', 'onboarding.s6.title': 'Gaming Starter Board',
'onboarding.s6.text': 'Do you play games like Satisfactory, Factorio or Star Citizen? I can create a board with useful community links.', 'onboarding.s6.text': 'Do you play games like Satisfactory, Factorio or Star Citizen? I can create a board with useful community links.',
'onboarding.tradecenter_desc': 'Trade Center for Star Citizen',
'onboarding.s7.title': 'Ready!', 'onboarding.s7.title': 'Ready!',
'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!', 'onboarding.s7.text': 'Create your first board with "+ Board" or import your browser bookmarks via the Import button in the header. Have fun!',
@@ -372,6 +495,110 @@ const STRINGS = {
'calculator.title': 'Calculator', 'calculator.title': 'Calculator',
'calculator.history': 'History', 'calculator.history': 'History',
'calculator.error': 'Error', 'calculator.error': 'Error',
'calculator.tab.standard': 'Standard',
'calculator.tab.scientific': 'Scientific',
'calculator.sci.formulas': 'Formula Helper',
'calculator.sci.select_formula': 'Choose formula…',
'calculator.sci.formula.circle_area': 'Circle Area (π×r²)',
'calculator.sci.formula.circle_circumference':'Circle Circumference (2πr)',
'calculator.sci.formula.celsius_to_fahrenheit':'°C → °F',
'calculator.sci.formula.fahrenheit_to_celsius':'°F → °C',
'calculator.sci.formula.pythagoras': 'Pythagoras (√(a²+b²))',
'calculator.sci.formula.percentage': 'Percentage',
'calculator.sci.field.radius': 'Radius',
'calculator.sci.field.temp': 'Temperature',
'calculator.sci.field.a': 'Side a',
'calculator.sci.field.b': 'Side b',
'calculator.sci.field.value': 'Value',
'calculator.sci.field.percent': 'Percent',
'calculator.tab.converter': 'Converter',
'calculator.conv.swap': 'Swap units',
'calculator.conv.cat.length': 'Length',
'calculator.conv.cat.weight': 'Weight',
'calculator.conv.cat.temperature': 'Temperature',
'calculator.conv.cat.volume': 'Volume',
'calculator.conv.cat.speed': 'Speed',
'calculator.conv.cat.area': 'Area',
'calculator.tab.satisfactory': 'Satisfactory',
'calculator.sat.tab.itemsPerMin': 'Items/Min',
'calculator.sat.tab.power': 'Power',
'calculator.sat.tab.machines': 'Machines',
'calculator.sat.items_per_craft': 'Items/Craft',
'calculator.sat.craft_time': 'Craft Time (s)',
'calculator.sat.clock_speed': 'Clock Speed (%)',
'calculator.sat.base_power': 'Base Power (MW)',
'calculator.sat.target_output': 'Target Output/Min',
'calculator.sat.output_per_min': 'Output',
'calculator.sat.power_usage': 'Power Usage',
'calculator.sat.efficiency': 'Efficiency',
'calculator.sat.per_item': 'per item',
'calculator.sat.machines_needed': 'Machines needed',
'calculator.sat.total_power': 'Total Power',
// Factorio Calculator
'calculator.tab.factorio': 'Factorio',
'calculator.fac.tab.ratio': 'Ratio',
'calculator.fac.tab.belt': 'Belt',
'calculator.fac.tab.machines': 'Machines',
'calculator.fac.assembler': 'Assembler',
'calculator.fac.asm.asm1': 'Assembler 1',
'calculator.fac.asm.asm2': 'Assembler 2',
'calculator.fac.asm.asm3': 'Assembler 3',
'calculator.fac.belt': 'Belt Type',
'calculator.fac.belt.yellow': 'Yellow',
'calculator.fac.belt.red': 'Red',
'calculator.fac.belt.blue': 'Blue',
'calculator.fac.recipe_output': 'Recipe Output',
'calculator.fac.recipe_time': 'Recipe Time (s)',
'calculator.fac.consume_per_sec': 'Consume/s',
'calculator.fac.target_output_sec': 'Target Output/s',
'calculator.fac.items_per_sec': 'Items/s',
'calculator.fac.items_per_min': 'Items/min',
'calculator.fac.machines_per_belt': 'Machines/Belt',
'calculator.fac.belt_utilization': 'Belt Utilization',
'calculator.fac.machines_needed': 'Machines needed',
'calculator.fac.belt_needed': 'Belt needed',
'calculator.fac.exceeds_belt': 'Exceeds max belt',
// Stationeers Calculator
'calculator.tab.stationeers': 'Stationeers',
'calculator.sta.tab.gas': 'Gas',
'calculator.sta.tab.furnace': 'Furnace',
'calculator.sta.tab.solar': 'Solar',
'calculator.sta.tab.atmo': 'Atmo',
'calculator.sta.solve_for': 'Solve for',
'calculator.sta.var.P': 'Pressure (P)',
'calculator.sta.var.V': 'Volume (V)',
'calculator.sta.var.n': 'Amount (n)',
'calculator.sta.var.T': 'Temperature (T)',
'calculator.sta.var.P_label': 'Pressure (kPa)',
'calculator.sta.var.V_label': 'Volume (L)',
'calculator.sta.var.n_label': 'Amount (mol)',
'calculator.sta.var.T_label': 'Temperature (K)',
'calculator.sta.result': 'Result',
'calculator.sta.fuel_ratio': 'Fuel Ratio (0-1)',
'calculator.sta.start_temp': 'Start Temperature (K)',
'calculator.sta.start_pressure': 'Start Pressure (kPa)',
'calculator.sta.temp_after': 'T after ignition',
'calculator.sta.pressure_after': 'P after ignition',
'calculator.sta.warn_low_fuel': '\u26A0 Fuel below 5%',
'calculator.sta.warn_low_pressure': '\u26A0 Pressure below 10 kPa',
'calculator.sta.panels': 'Panel Count',
'calculator.sta.watts_per_panel': 'Watts/Panel',
'calculator.sta.day_length': 'Day Length (s)',
'calculator.sta.night_length': 'Night Length (s)',
'calculator.sta.consumption': 'Consumption (W)',
'calculator.sta.generation': 'Generation',
'calculator.sta.surplus': 'Surplus',
'calculator.sta.night_energy': 'Night Energy',
'calculator.sta.batteries_needed': 'Batteries needed',
'calculator.sta.target_temp': 'Target Temperature (K)',
'calculator.sta.gas1_temp': 'Gas 1 Temperature (K)',
'calculator.sta.gas2_temp': 'Gas 2 Temperature (K)',
'calculator.sta.mixer_input1': 'Mixer Input 1',
'calculator.sta.mixer_input2': 'Mixer Input 2',
'calculator.sta.heat_cap_ref': 'Heat Capacities (Reference)',
'calculator.sta.gas': 'Gas',
// Timer // Timer
'timer.title': 'Timer', 'timer.title': 'Timer',
@@ -493,6 +720,13 @@ const STRINGS = {
'header.theme': 'Theme', 'header.theme': 'Theme',
'header.settings': 'Settings', 'header.settings': 'Settings',
// Header Tooltips
'header.import_title': 'Import bookmarks (HTML)',
'header.board_title': 'Add new board',
'header.note_title': 'Quick note',
'header.theme_title': 'Appearance & Theme',
'header.settings_title': 'Settings',
// Settings panel heading // Settings panel heading
'settings.title': 'Settings', 'settings.title': 'Settings',
@@ -546,6 +780,13 @@ const STRINGS = {
'settings.bg_upload.desc': 'Use a local image as background', 'settings.bg_upload.desc': 'Use a local image as background',
'settings.search_engine_toggle': 'Switch search engine', 'settings.search_engine_toggle': 'Switch search engine',
// Settings Buttons + Validation
'settings.onboarding_btn': 'Start',
'settings.reset_btn': 'Reset',
'settings.bg_upload_btn': 'Upload',
'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.',
'settings.bg_invalid_url.title': 'Invalid URL',
// Modals // Modals
'modal.new_board': 'New Board', 'modal.new_board': 'New Board',
'modal.board_name': 'Board name...', 'modal.board_name': 'Board name...',
@@ -576,6 +817,7 @@ const STRINGS = {
// Search // Search
'search.placeholder': 'Search the web…', 'search.placeholder': 'Search the web…',
'search.submit_title': 'Search',
// Widget toolbar tooltips // Widget toolbar tooltips
'toolbar.note': 'Create note', 'toolbar.note': 'Create note',
@@ -619,7 +861,9 @@ function applyLanguage() {
el.placeholder = t(el.dataset.i18nPlaceholder); el.placeholder = t(el.dataset.i18nPlaceholder);
}); });
document.querySelectorAll('[data-i18n-title]').forEach(el => { document.querySelectorAll('[data-i18n-title]').forEach(el => {
el.title = t(el.dataset.i18nTitle); const text = t(el.dataset.i18nTitle);
el.title = text;
el.setAttribute('aria-label', text);
}); });
} }
+16 -25
View File
@@ -294,7 +294,7 @@ const ImageRef = {
// Widget-Titel aktualisieren // Widget-Titel aktualisieren
const entry = WidgetManager._widgets.get(imageData.id); const entry = WidgetManager._widgets.get(imageData.id);
if (entry) { if (entry) {
const titleEl = entry.el.querySelector('.widget-title-text'); const titleEl = entry.el.querySelector('.widget-title');
if (titleEl) titleEl.textContent = text || t('imageref.title'); if (titleEl) titleEl.textContent = text || t('imageref.title');
entry.state.title = text || t('imageref.title'); entry.state.title = text || t('imageref.title');
} }
@@ -460,41 +460,32 @@ const ImageRef = {
}); });
} }
// Close-Event abfangen // Widget-Lifecycle-Events
const self = this; const self = this;
const prevClose = WidgetManager.close; WidgetManager.on('widget:close', (e) => {
WidgetManager.close = function(id) { const isImage = self._images.some(img => img.id === e.detail.id);
prevClose.call(WidgetManager, id);
// Pruefen ob es ein Image-Widget ist
const isImage = self._images.some(img => img.id === id);
if (isImage) { if (isImage) {
self.onClose(id); self.onClose(e.detail.id);
} }
}; });
// Minimize-Event abfangen WidgetManager.on('widget:minimize', (e) => {
const prevMinimize = WidgetManager.minimize; const isImage = self._images.some(img => img.id === e.detail.id);
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
const isImage = self._images.some(img => img.id === id);
if (isImage) { if (isImage) {
await self.save(); self.save();
} }
}; });
// Open-Event abfangen WidgetManager.on('widget:open', (e) => {
const prevOpen = WidgetManager.openWidget; const imgData = self._images.find(img => img.id === e.detail.id);
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
const imgData = self._images.find(img => img.id === id);
if (imgData) { if (imgData) {
const body = WidgetManager.getBody(id); const body = WidgetManager.getBody(e.detail.id);
if (body && body.children.length === 0) { if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(id); const dataUrl = self._getSessionImage(e.detail.id);
self.renderBody(imgData, body, dataUrl); self.renderBody(imgData, body, dataUrl);
} }
await self.save(); self.save();
} }
}; });
} }
}; };
+1 -1
View File
@@ -215,7 +215,7 @@ const Onboarding = {
{ id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' }, { id: uid(), title: 'Modrinth (Mods)', url: 'https://modrinth.com', desc: '' },
{ id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' }, { id: uid(), title: 'Star Citizen Wiki', url: 'https://starcitizen.tools', desc: '' },
{ id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', desc: '' }, { id: uid(), title: 'UEX Corp (Trading)', url: 'https://uexcorp.space', desc: '' },
{ id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: 'Trade Center f\u00FCr Star Citizen' } { id: uid(), title: 'Hellion TradeCenter', url: 'https://hellion-initiative.online/tradecenter', desc: t('onboarding.tradecenter_desc') }
], ],
blurred: false blurred: false
}; };
+19 -1
View File
@@ -23,6 +23,17 @@ function closeThemeModal() {
overlay.classList.remove('active'); overlay.classList.remove('active');
} }
/**
* Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist.
* Erlaubt nur blob: und data:image/ Protokolle (aus File Upload).
* @param {string} url
* @returns {boolean}
*/
function isValidBgUrl(url) {
return typeof url === 'string' && url.length > 0 &&
(url.startsWith('blob:') || url.startsWith('data:image/'));
}
// ---- ACCORDION ---- // ---- ACCORDION ----
function initAccordion() { function initAccordion() {
const defaultOpen = new Set(['widgets']); const defaultOpen = new Set(['widgets']);
@@ -89,8 +100,10 @@ function applySettings() {
applyTheme(settings.theme || 'nebula', !!settings.bgUrl); applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
if (settings.bgUrl) { if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) {
document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`; document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`;
} else if (settings.bgUrl) {
settings.bgUrl = '';
} }
} }
@@ -168,6 +181,10 @@ function bindSettingsEvents() {
}); });
document.getElementById('btnApplyBg').addEventListener('click', async () => { document.getElementById('btnApplyBg').addEventListener('click', async () => {
const url = document.getElementById('bgUrlInput').value.trim(); const url = document.getElementById('bgUrlInput').value.trim();
if (url && !isValidBgUrl(url)) {
await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') });
return;
}
settings.bgUrl = url; settings.bgUrl = url;
document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : ''; document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : '';
await saveSettings(); await saveSettings();
@@ -183,6 +200,7 @@ function bindSettingsEvents() {
if (!file) return; if (!file) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async ev => { reader.onload = async ev => {
if (!isValidBgUrl(ev.target.result)) return;
settings.bgUrl = ev.target.result; settings.bgUrl = ev.target.result;
document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`; document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`;
await saveSettings(); await saveSettings();
-9
View File
@@ -33,15 +33,6 @@ function escHtml(str) {
.replace(/"/g, '&quot;'); .replace(/"/g, '&quot;');
} }
function getFaviconUrl(url) {
try {
const u = new URL(url);
return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`;
} catch {
return '';
}
}
function getDefaultBoards() { function getDefaultBoards() {
return [ return [
{ {
+12 -21
View File
@@ -720,32 +720,23 @@ const Timer = {
await this.open(); await this.open();
} }
// Close-Event abfangen // Widget-Lifecycle-Events
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this; const self = this;
const prevClose = WidgetManager.close; WidgetManager.on('widget:close', (e) => {
WidgetManager.close = function(id) { if (e.detail.id === self.WIDGET_ID) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose(); self.onClose();
} }
}; });
// Minimize-Event abfangen WidgetManager.on('widget:minimize', (e) => {
const prevMinimize = WidgetManager.minimize; if (e.detail.id === self.WIDGET_ID) {
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false; self._isOpen = false;
await self.save(); self.save();
} }
}; });
// Open-Event abfangen WidgetManager.on('widget:open', (e) => {
const prevOpen = WidgetManager.openWidget; if (e.detail.id === self.WIDGET_ID) {
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = true; self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID); const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) { if (body && body.children.length === 0) {
@@ -753,8 +744,8 @@ const Timer = {
} }
const entry = WidgetManager._widgets.get(self.WIDGET_ID); const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el); if (entry) self._bindKeyboard(entry.el);
await self.save(); self.save();
} }
}; });
} }
}; };
+52 -5
View File
@@ -9,6 +9,27 @@ const WidgetManager = {
_topZ: 100, _topZ: 100,
STORAGE_KEY: 'widgetStates', STORAGE_KEY: 'widgetStates',
/** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */
_emitter: new EventTarget(),
/**
* Event-Listener registrieren
* @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open'
* @param {Function} handler
*/
on(event, handler) {
this._emitter.addEventListener(event, handler);
},
/**
* Event-Listener entfernen
* @param {string} event
* @param {Function} handler
*/
off(event, handler) {
this._emitter.removeEventListener(event, handler);
},
/** /**
* Widget erstellen und in DOM einfuegen * Widget erstellen und in DOM einfuegen
* @param {string} type - 'note' * @param {string} type - 'note'
@@ -31,7 +52,7 @@ const WidgetManager = {
const el = this._buildDOM(state); const el = this._buildDOM(state);
document.body.appendChild(el); document.body.appendChild(el);
this._widgets.set(id, { el, type, state }); this._widgets.set(id, { el, type, state, _minimizing: false });
this._initDrag(el); this._initDrag(el);
this._initResize(el); this._initResize(el);
this.bringToFront(id); this.bringToFront(id);
@@ -144,22 +165,47 @@ const WidgetManager = {
const entry = this._widgets.get(id); const entry = this._widgets.get(id);
if (!entry) return; if (!entry) return;
entry.el.remove(); entry.el.remove();
this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } }));
this._widgets.delete(id); this._widgets.delete(id);
}, },
/** /**
* Widget minimieren (aus DOM verstecken, bleibt im Notebook) * Widget minimieren (aus DOM verstecken, bleibt im Notebook).
* Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition
* mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition.
* @param {string} id * @param {string} id
*/ */
async minimize(id) { async minimize(id) {
const entry = this._widgets.get(id); const entry = this._widgets.get(id);
if (!entry) return; if (!entry) return;
entry.state.open = false; entry.state.open = false;
entry._minimizing = true;
entry.el.classList.add('widget-minimized'); entry.el.classList.add('widget-minimized');
setTimeout(() => {
const MINIMIZE_FALLBACK_MS = 350;
function onEnd(e) {
if (e.target !== entry.el || e.propertyName !== 'opacity') return;
clearTimeout(fallbackTimer);
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none'; entry.el.style.display = 'none';
}, 250); }
entry._minimizing = false;
}
entry.el.addEventListener('transitionend', onEnd);
const fallbackTimer = setTimeout(() => {
entry.el.removeEventListener('transitionend', onEnd);
if (entry._minimizing) {
entry.el.style.display = 'none';
entry._minimizing = false;
}
}, MINIMIZE_FALLBACK_MS);
await this.save(); await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } }));
}, },
/** /**
@@ -169,14 +215,15 @@ const WidgetManager = {
async openWidget(id) { async openWidget(id) {
const entry = this._widgets.get(id); const entry = this._widgets.get(id);
if (!entry) return; if (!entry) return;
entry._minimizing = false;
entry.state.open = true; entry.state.open = true;
entry.el.style.display = 'flex'; entry.el.style.display = 'flex';
// Naechster Frame fuer Animation
requestAnimationFrame(() => { requestAnimationFrame(() => {
entry.el.classList.remove('widget-minimized'); entry.el.classList.remove('widget-minimized');
}); });
this.bringToFront(id); this.bringToFront(id);
await this.save(); await this.save();
this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } }));
}, },
/** /**