4 Commits

Author SHA1 Message Date
JonKazama-Hellion 8dc7a73811 feat(app): Onboarding, Settings-Redesign und Docs für v1.9.0
- Onboarding mit Widget-Toolbar Slide und Gaming Starter Board
- Settings in Darstellung-Modal und schlankes Settings-Panel
- About-Block als fixierten Footer im Settings-Panel
- Dropdown-Optionen an Theme-Farben anpassen
- Projekt-Dokumentation (Architektur, Widget-Schema, Patterns)
- Firefox Update-URL für Store-Veröffentlichung
- Versions-Bump auf 1.9.0 in allen Manifests
2026-03-22 08:54:57 +01:00
JonKazama-Hellion f08d5d7563 feat(image-ref): Bild-Referenz Widget mit Session-Storage
Opt-in Widget fuer Bild-Referenzen (max. 3 gleichzeitig).
Canvas API konvertiert zu WebP, sessionStorage fuer Bilddaten.
Positionen und Labels bleiben persistent, Bilder nur pro Session.
2026-03-22 00:47:51 +01:00
JonKazama-Hellion b55bb7ac34 feat(timer): Timer/Countdown-Widget mit Presets und Alarm
Countdown-Timer als Single-Instance-Widget mit Preset-System
(max. 5), Web Audio API Alarm und Tab-Titel-Blink bei Ablauf.
Mute-Toggle zum Stummschalten des Alarms.
Z-Index-Hierarchie für Widgets auf 100 angehoben.
2026-03-22 00:32:41 +01:00
JonKazama-Hellion 37e45a2041 feat(calculator): Taschenrechner-Widget mit History und Tastatureingabe
Neues Widget-Modul mit Shunting-Yard Parser, 4x5 Button-Grid,
persistenter History (max 10) und Keyboard-Support.
Storage-Handling in Notes/Data erweitert fuer parallele Persistierung.
2026-03-22 00:13:40 +01:00
21 changed files with 3528 additions and 208 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
ko_fi: hellionmedia
+4
View File
@@ -16,6 +16,10 @@ dist/
node_modules/
/xpi/
v2-planning.md
themes-v2.md
# Firefox Update-Manifest (wird auf hellion-media.de gehostet)
updates.json
# Persönliche Backup-Dateien (nicht ins Repo)
favorites_*.html
+2 -2
View File
@@ -1,6 +1,6 @@
# ⬡ Hellion Dashboard v1.5.2
# ⬡ Hellion Dashboard v1.9.0
![Version](https://img.shields.io/badge/Version-1.5.2-blue)
![Version](https://img.shields.io/badge/Version-1.9.0-blue)
![JavaScript](https://img.shields.io/badge/JavaScript-Vanilla%20ES2020-F7DF1E?logo=javascript&logoColor=black)
![Manifest](https://img.shields.io/badge/Manifest-V3-green)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange)
+163
View File
@@ -0,0 +1,163 @@
# Hellion Dashboard — Architecture
## Overview
Hellion Dashboard is a browser extension (NewTab replacement) built with **Vanilla JavaScript ES2020**, **CSS Custom Properties**, and **zero dependencies**. No build step, no framework, no bundler — files are loaded directly via `<script>` tags.
**Storage:** `chrome.storage.local` with `localStorage` fallback.
**Manifest:** V3 for Chromium browsers, V3 for Firefox (separate manifest).
---
## File Structure
```
HOM_NewTab_Project/
├── newtab.html # Single HTML entry point
├── manifest.json # Chrome/Edge/Brave/Vivaldi (MV3)
├── manifest.firefox.json # Firefox (MV3)
├── manifest.opera.json # Opera/Opera GX (MV3 + workarounds)
├── src/
│ ├── css/
│ │ └── main.css # All styles, themes, responsive breakpoints
│ └── js/
│ ├── storage.js # Storage abstraction layer
│ ├── state.js # Global state, defaults, helpers
│ ├── themes.js # Theme definitions & application
│ ├── boards.js # Board/bookmark rendering & events
│ ├── drag.js # Drag & drop (Pointer Events API)
│ ├── settings.js # Settings panel, toggles, theme picker
│ ├── search.js # Search bar (Google, DuckDuckGo, Bing)
│ ├── widgets.js # Widget manager (registry, drag, resize)
│ ├── notes.js # Notes/checklists (multi-instance widgets)
│ ├── calculator.js # Calculator widget (single-instance)
│ ├── timer.js # Timer/countdown widget (single-instance)
│ ├── image-ref.js # Image reference widget (multi-instance)
│ ├── onboarding.js # First-run onboarding flow
│ ├── data.js # JSON export/import (backup & restore)
│ ├── app.js # Init, clock, global events (entry point)
│ └── dialog.js # Custom dialog system (alert, confirm)
├── assets/
│ ├── icons/ # Extension icons (16-512px)
│ └── themes/ # Theme background images
└── docs/ # Documentation (you are here)
```
---
## Module Responsibilities
Each module has exactly one responsibility. They communicate through global references (no import/export — this is a browser extension without a bundler).
| Module | Responsibility |
|---|---|
| `storage.js` | **Only** place that touches `chrome.storage` / `localStorage`. All other modules go through `Store.get()` / `Store.set()`. |
| `state.js` | Global `boards` and `settings` arrays, default values, `uid()`, `escHtml()`, `getFaviconUrl()`. |
| `themes.js` | Theme CSS variable application. 8 themes, each with its own `[data-theme]` block in CSS. |
| `boards.js` | Renders boards and bookmarks. Event delegation on board containers. |
| `drag.js` | Board and bookmark reordering via Pointer Events API. |
| `settings.js` | Settings panel UI, toggle handlers, theme modal, background upload. |
| `search.js` | Search bar with engine switching (Google, DuckDuckGo, Bing). |
| `widgets.js` | Widget manager — creates DOM, handles drag/resize/z-index, provides registry. See [widget-schema.md](widget-schema.md). |
| `notes.js` | Notes and checklists as widgets. Multi-instance (max 5). Notebook sidebar. Also handles widget toolbar events. |
| `calculator.js` | Calculator widget. Single-instance. Shunting-yard expression parser (no `eval()`). |
| `timer.js` | Timer/countdown widget. Single-instance. Presets, Web Audio API alarm, tab-title blink. |
| `image-ref.js` | Image reference widget. Multi-instance (max 3). Canvas API WebP conversion, sessionStorage for image data. |
| `onboarding.js` | Multi-slide onboarding flow. Gaming starter board opt-in. |
| `data.js` | JSON export/import with validation. Handles boards, notes, calculator history, timer presets. |
| `app.js` | Entry point. Calls `init()` on DOMContentLoaded. Clock, global event binding. |
| `dialog.js` | `HellionDialog.alert()` and `HellionDialog.confirm()` — custom styled dialogs replacing native browser dialogs. |
---
## Init Sequence
```
DOMContentLoaded
→ init()
→ Store.get('boards') # Load saved boards
→ Store.get('settings') # Load saved settings
→ applySettings() # Apply theme, toggles, etc.
→ renderBoards() # Render all boards
→ startClock() # Start clock/date display
→ bindGlobalEvents() # Header buttons, modals
→ bindSettingsEvents() # Settings toggles, theme picker
→ initSearch() # Search bar
→ migrateSticky() # Legacy sticky note migration
→ Notes.init() # Notes + widget toolbar
→ Calculator.init() # Calculator widget
→ Timer.init() # Timer widget
→ ImageRef.init() # Image reference widget
→ initDataButtons() # Export/import buttons
→ Onboarding check # First-run onboarding
```
---
## Script Load Order
Scripts are loaded in `newtab.html` in dependency order:
```html
<script src="src/js/dialog.js"></script>
<script src="src/js/storage.js"></script>
<script src="src/js/state.js"></script>
<script src="src/js/themes.js"></script>
<script src="src/js/boards.js"></script>
<script src="src/js/drag.js"></script>
<script src="src/js/settings.js"></script>
<script src="src/js/search.js"></script>
<script src="src/js/onboarding.js"></script>
<script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script>
<script src="src/js/timer.js"></script>
<script src="src/js/image-ref.js"></script>
<script src="src/js/data.js"></script>
<script src="src/js/app.js"></script>
```
**Rule:** A module may only reference modules loaded before it.
---
## Z-Index Hierarchy
| Layer | z-index | Elements |
|---|---|---|
| Background | 0-2 | `#bgLayer`, boards |
| Search bar | 90 | `.search-bar-wrapper` |
| Widgets + Toolbar | 100+ | `.widget`, `.widget-toolbar` |
| Header | 100 | `#header` |
| Settings panel | 200 | `#settingsPanel` |
| Dialogs / Modals | 300 | `.hellion-dialog-overlay`, modals |
| Onboarding | 400 | `#onboardingOverlay` |
Widgets use incrementing z-index (`WidgetManager._topZ++`) to stack above each other on click.
---
## Storage Keys
| Key | Type | Content |
|---|---|---|
| `boards` | Array | Board objects with bookmarks |
| `settings` | Object | User preferences (theme, toggles, etc.) |
| `widgetStates` | Object | All widget data (see [widget-schema.md](widget-schema.md)) |
| `onboardingDone` | Boolean | Whether onboarding has been completed |
| `lastBackupReminder` | Number | Timestamp of last backup reminder |
---
## Browser Compatibility
| Browser | Engine | Manifest |
|---|---|---|
| Chrome | Chromium MV3 | `manifest.json` |
| Edge | Chromium MV3 | `manifest.json` |
| Brave | Chromium MV3 | `manifest.json` |
| Vivaldi | Chromium MV3 | `manifest.json` |
| Opera / GX | Chromium MV3 | `manifest.opera.json` |
| Firefox | Gecko MV3 | `manifest.firefox.json` |
Changes affecting manifest fields must be synchronized across all three manifest files.
+310
View File
@@ -0,0 +1,310 @@
# Hellion Dashboard — Code Patterns & Conventions
## Core Principles
- **Vanilla JS ES2020** — No frameworks, no TypeScript, no build step
- **Zero dependencies** — Everything is built from scratch
- **`createElement` only** — Never use `innerHTML` (XSS prevention)
- **CSS Custom Properties** — No hardcoded colors, everything through `var(--name)`
- **Event delegation** — One listener per container, not per element
- **Storage abstraction** — All storage access through `Store.get()` / `Store.set()`
---
## Pattern: Storage Abstraction
**File:** `src/js/storage.js`
All persistent data goes through the `Store` object. Never access `chrome.storage` or `localStorage` directly.
```javascript
// Reading
const boards = await Store.get('boards'); // Returns null if not found
const settings = await Store.get('settings');
// Writing
await Store.set('boards', boards);
await Store.set('settings', settings);
// Quota check (chrome.storage only, 10 MB limit)
await Store.checkQuota();
```
**Why?** The `Store` handles the chrome.storage / localStorage fallback transparently. It also provides unified error handling (shows a dialog when storage is full).
---
## Pattern: Event Delegation
Instead of attaching listeners to each element, attach one to the container and use `closest()` to find the target.
```javascript
// GOOD — one listener, handles all bookmarks
container.addEventListener('click', (e) => {
const bmItem = e.target.closest('.bm-item');
if (!bmItem) return;
const id = bmItem.dataset.id;
// Handle click
});
// BAD — listener per element
bookmarks.forEach(bm => {
bm.addEventListener('click', handler); // Don't do this!
});
```
**Used in:** `boards.js` (board/bookmark events), `notes.js` (toolbar), `calculator.js` (button grid)
---
## Pattern: createElement over innerHTML
Always build DOM with `document.createElement()`. This prevents XSS and is the project's #1 security rule.
```javascript
// GOOD
const link = document.createElement('a');
link.href = bookmark.url;
link.textContent = bookmark.title;
container.appendChild(link);
// BAD — XSS risk!
container.innerHTML = `<a href="${url}">${title}</a>`;
```
---
## Pattern: Shared Storage Key
Multiple widget modules share the `widgetStates` key. Every module must read-before-write and preserve other modules' data.
```javascript
async save() {
const data = await Store.get('widgetStates') || {};
// Write your own data
data.yourKey = { /* ... */ };
// DON'T overwrite — the key already contains other modules' data
await Store.set('widgetStates', data);
}
```
See [widget-schema.md](widget-schema.md) for the full `widgetStates` structure.
---
## Pattern: Widget Lifecycle Hooks
Single-instance widgets (Calculator, Timer) need to know when they're closed, minimized, or reopened. They wrap `WidgetManager` methods in their `init()`:
```javascript
async init() {
// Wrap close
const prevClose = WidgetManager.close;
const self = this;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Wrap minimize
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
}
```
**Important:** Multiple widgets chain these wraps. Calculator wraps first, Timer wraps Calculator's already-wrapped version, and so on. The chain must not break.
---
## Pattern: Debounced Save
For frequent updates (typing in notes, moving widgets), use debounced saves to avoid excessive storage writes:
```javascript
_saveTimer: null,
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
}
// Usage: call _debouncedSave() instead of save() for frequent events
textarea.addEventListener('input', () => {
noteData.content = textarea.value;
this._debouncedSave();
});
```
**Used in:** `notes.js` (text editing), `image-ref.js` (label editing)
---
## Pattern: Theme System
All themes use CSS Custom Properties defined in `[data-theme="name"]` blocks:
```css
[data-theme="nebula"] {
--bg-primary: #0a0e17;
--bg-board: rgba(15, 20, 35, 0.65);
--text-primary: #e0e6f0;
--accent: #7db3ff;
--border: rgba(125, 179, 255, 0.12);
/* ... more variables */
}
```
**Never hardcode colors in JS.** Use CSS classes or variables:
```javascript
// GOOD — let CSS handle colors
element.classList.add('active');
// BAD — hardcoded color
element.style.color = '#7db3ff';
```
8 themes are available: Nebula, Crescent, Event Horizon, Merchantman, Julia & Jin, SC Sunset, Hellion HUD, Hellion Energy.
---
## Pattern: Onboarding Slides
The onboarding system (`onboarding.js`) uses a data-driven slide array. Each slide is an object with rendering hints:
```javascript
{
hero: '🎮', // Large emoji/icon
title: 'Slide Title', // Heading
text: 'Description...', // Optional text paragraph
features: ['Item 1', ...], // Optional bullet list
showThemes: true, // Optional theme grid
interactive: 'gaming-board' // Optional custom buttons
}
```
The `_render()` method reads these properties and builds the DOM. To add a new slide, just add an object to the `slides` array.
---
## Pattern: Dialog System
Custom dialogs replace native `alert()` and `confirm()`:
```javascript
// Alert (informational)
await HellionDialog.alert('Message text', {
type: 'info', // 'info', 'success', 'warning', 'danger'
title: 'Title'
});
// Confirm (yes/no)
const ok = await HellionDialog.confirm('Are you sure?', {
type: 'danger',
title: 'Delete',
confirmText: 'Delete', // Custom button text
cancelText: 'Cancel'
});
if (ok) { /* user confirmed */ }
```
---
## Pattern: Pointer Events for Drag
Widget dragging and board reordering use the Pointer Events API (not mouse events):
```javascript
element.addEventListener('pointerdown', (e) => {
element.setPointerCapture(e.pointerId);
function onMove(ev) {
// Update position
}
function onUp() {
element.releasePointerCapture(e.pointerId);
element.removeEventListener('pointermove', onMove);
element.removeEventListener('pointerup', onUp);
}
element.addEventListener('pointermove', onMove);
element.addEventListener('pointerup', onUp);
});
```
**Why Pointer Events over Mouse Events?** They work with both mouse and touch, and `setPointerCapture` ensures events continue even if the cursor leaves the element.
---
## Pattern: Canvas API Image Processing
The image reference widget converts uploaded images to WebP for smaller size:
```javascript
_processFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const webpUrl = canvas.toDataURL('image/webp', 0.85);
URL.revokeObjectURL(objectUrl);
resolve(webpUrl);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Image could not be loaded'));
};
img.src = objectUrl;
});
}
```
**Important:** Always call `URL.revokeObjectURL()` to free memory.
---
## Coding Rules Summary
| Rule | Rationale |
|---|---|
| `createElement` only, never `innerHTML` | XSS prevention |
| All storage through `Store` | Browser compatibility |
| CSS variables, no hardcoded colors | Theme support |
| Event delegation | Performance, dynamic content |
| `const`/`let`, never `var` | Block scoping |
| No external dependencies | Extension simplicity |
| No build step | Direct development |
| JSDoc comments on public functions | Documentation |
| URL validation before `href` | Security |
| Error handling on storage operations | Graceful failure |
---
## Manifest Synchronization
Three manifest files must stay in sync:
- `manifest.json` — Chrome, Edge, Brave, Vivaldi
- `manifest.firefox.json` — Firefox
- `manifest.opera.json` — Opera, Opera GX
When changing version numbers, permissions, or content script entries, update all three files.
+330
View File
@@ -0,0 +1,330 @@
# Hellion Dashboard — Widget Schema
## Overview
The widget system provides draggable, resizable floating panels managed by `WidgetManager` (`src/js/widgets.js`). Each widget type has its own module that handles content rendering and state management.
---
## Widget Types
| Type | Module | Instance | Max | Storage |
|---|---|---|---|---|
| `note` | `notes.js` | Multi | 5 | Persistent (`widgetStates.notes`) |
| `calculator` | `calculator.js` | Single | 1 | Persistent (`widgetStates.calculator`) |
| `timer` | `timer.js` | Single | 1 | Persistent (`widgetStates.timer`) |
| `image` | `image-ref.js` | Multi | 3 | Meta: persistent, Image data: sessionStorage |
---
## WidgetManager API
### `create(type, config) → string`
Creates a widget and appends it to the DOM.
```javascript
const id = WidgetManager.create('note', {
id: 'note_abc123', // Optional, auto-generated if omitted
title: 'My Note', // Default: 'Note'
x: 120, // Left position in px
y: 80, // Top position in px
width: 280, // Width in px (min: 200)
height: 220, // Height in px (min: 150)
open: true // Visible state (default: true)
});
```
### `getBody(id) → HTMLElement | null`
Returns the `.widget-body` element for content rendering.
```javascript
const body = WidgetManager.getBody('widget_calculator');
if (body) Calculator.renderBody(body);
```
### `getState(id) → Object | null`
Returns the current widget state (position, size, open status).
```javascript
const state = WidgetManager.getState('widget_timer');
// → { id, type, title, x, y, width, height, open }
```
### `close(id)`
Permanently removes a widget from the DOM and registry.
### `minimize(id)`
Hides a widget with animation. Widget remains in registry with `open: false`.
### `openWidget(id)`
Restores a minimized widget with animation.
### `bringToFront(id)`
Increments z-index to bring widget above all others.
### `save() → Array`
Returns an array of all `type: 'note'` widget states. Used by `Notes.save()` to merge with note content data.
### `restore(renderCallback)`
Loads widget states from storage and recreates all note widgets. Only handles notes — single-instance widgets (calculator, timer) restore themselves in their own `init()`.
---
## Shared Storage Key: `widgetStates`
All widget modules share a single storage key. Each module's `save()` method must preserve other modules' data.
```javascript
// Structure of widgetStates
{
notes: [
{
id: 'note_abc123',
title: 'My Note',
content: 'Hello world',
template: 'text', // 'text' or 'checklist'
x: 120, y: 80,
width: 280, height: 220,
open: true,
checklistItems: [], // For checklist template
checkedItems: [] // Checked item IDs
}
],
calculator: {
x: 400, y: 120,
width: 280, height: 400,
open: false,
history: [
{ expr: '2 + 3', result: '5' }
]
},
timer: {
x: 600, y: 80,
width: 260, height: 360,
open: false,
muted: false,
presets: [
{ name: 'Forschung', seconds: 2700 }
]
},
imageRef: {
images: [
{
id: 'image_0',
label: 'Bauplan',
x: 200, y: 120,
width: 320, height: 280,
open: true
}
]
}
}
```
### Save Pattern — Preserving Other Modules' Data
Every module that saves to `widgetStates` must read existing data first and preserve keys it doesn't own:
```javascript
// Example from notes.js
async save() {
const existing = await Store.get(this.STORAGE_KEY);
const saveData = { notes: mergedNotes };
// Preserve other modules
if (existing && existing.calculator) saveData.calculator = existing.calculator;
if (existing && existing.timer) saveData.timer = existing.timer;
if (existing && existing.imageRef) saveData.imageRef = existing.imageRef;
await Store.set(this.STORAGE_KEY, saveData);
}
```
---
## Creating a New Widget Type
### Step 1: Choose Single or Multi-Instance
- **Single-instance** (like Calculator, Timer): One widget with a fixed ID. `toggle()` opens/closes.
- **Multi-instance** (like Notes, ImageRef): Multiple widgets with dynamic IDs. `create()` adds new ones.
### Step 2: Create the Module (`src/js/your-widget.js`)
```javascript
const YourWidget = {
WIDGET_ID: 'widget_yourwidget', // Fixed ID for single-instance
STORAGE_KEY: 'widgetStates',
_isOpen: false,
// Load state from storage
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget) {
// Restore your state
}
},
// Save state, preserving other modules
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
const widgetState = WidgetManager.getState(this.WIDGET_ID);
data.yourWidget = {
x: widgetState ? widgetState.x : 400,
y: widgetState ? widgetState.y : 120,
width: widgetState ? widgetState.width : 280,
height: widgetState ? widgetState.height : 300,
open: this._isOpen,
// ... your custom data
};
await Store.set(this.STORAGE_KEY, data);
},
// Open widget
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.yourWidget) ? data.yourWidget : {};
WidgetManager.create('yourwidget', {
id: this.WIDGET_ID,
title: 'Your Widget',
x: saved.x || 400,
y: saved.y || 120,
width: saved.width || 280,
height: saved.height || 300,
open: true
});
const body = WidgetManager.getBody(this.WIDGET_ID);
if (body) this.renderBody(body);
this._isOpen = true;
await this.save();
},
// Toggle open/close
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
// Render widget content
renderBody(bodyEl) {
bodyEl.textContent = '';
// Build your UI with createElement (never innerHTML!)
},
// Initialize and hook into lifecycle
async init() {
await this.load();
// Restore if was open last time
const data = await Store.get(this.STORAGE_KEY);
if (data && data.yourWidget && data.yourWidget.open) {
await this.open();
}
// Hook into close event
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
self.save();
}
};
// Hook into minimize event
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Hook into open event
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
await self.save();
}
};
}
};
```
### Step 3: Integration Checklist
1. **`newtab.html`** — Add `<script>` tag (after `widgets.js`, before `data.js`)
2. **`newtab.html`** — Add toolbar button: `<button class="widget-toolbar-btn" data-action="your-action">`
3. **`notes.js`** — Add toolbar handler in `initToolbar()`: `} else if (action === 'your-action') { YourWidget.toggle(); }`
4. **`notes.js`** — Preserve your data in `save()`: `if (existing && existing.yourWidget) saveData.yourWidget = existing.yourWidget;`
5. **`app.js`** — Add `await YourWidget.init();` to the init sequence
6. **`src/css/main.css`** — Add widget-specific CSS styles
7. **`data.js`** — Add export/import logic (if data should be included in backups)
---
## Widget DOM Structure
Every widget created by `WidgetManager.create()` has this structure:
```html
<div class="widget" data-widget-id="widget_abc123"
style="left: 120px; top: 80px; width: 280px; height: 220px;">
<div class="widget-header">
<span class="widget-title">Title</span>
<div class="widget-actions">
<button class="widget-btn widget-minimize"></button>
<button class="widget-btn widget-close"></button>
</div>
</div>
<div class="widget-body">
<!-- Your content goes here (via renderBody) -->
</div>
<div class="widget-resize-handle"></div>
</div>
```
- **Header** is the drag handle (Pointer Events)
- **Title** supports double-click to edit (contentEditable, max 20 chars)
- **Body** is where your module renders content
- **Resize handle** appears on hover (bottom-right corner)
+2 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hellion NewTab",
"version": "1.5.2",
"version": "1.9.0",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
@@ -18,6 +18,7 @@
"browser_specific_settings": {
"gecko": {
"id": "hellion-newtab@hellion-media.de",
"update_url": "https://hellion-media.de/extensions/firefox/updates.json",
"strict_min_version": "142.0",
"data_collection_permissions": {
"required": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hellion NewTab",
"version": "1.5.2",
"version": "1.9.0",
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Hellion Dashboard (GX Native)",
"version": "1.5.2",
"version": "1.9.0",
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
"author": "Hellion Online Media - Florian Wathling",
"homepage_url": "https://hellion-media.de",
+115 -126
View File
@@ -35,9 +35,9 @@
<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>
Note
</button>
<button class="btn-icon" id="btnTheme" title="Theme wählen">
<button class="btn-icon" id="btnTheme" title="Darstellung & Theme">
<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>
Theme
Darstellung
</button>
<button class="btn-icon" id="btnSettings" title="Einstellungen">
<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>
@@ -67,6 +67,15 @@
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="timer" title="Timer">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
</button>
<button class="widget-toolbar-btn hidden" data-action="image-ref" title="Bild-Referenz">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</button>
<button class="widget-toolbar-btn" data-action="notebook" title="Alle Notes">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
</button>
@@ -96,84 +105,11 @@
<div class="panel-overlay" id="settingsOverlay"></div>
<aside class="settings-panel" id="settingsPanel">
<div class="panel-header">
<span>Settings</span>
<span>Einstellungen</span>
<button class="btn-close" id="btnCloseSettings"></button>
</div>
<div class="panel-body">
<!-- APPEARANCE -->
<section class="settings-section" data-section="appearance">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
APPEARANCE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Compact mode</span>
<span class="setting-desc">Reduce spacing to show more bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Shorten long titles</span>
<span class="setting-desc">Shorten title to one line with "…"</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
</div>
</div>
</section>
<!-- BEHAVIOR -->
<section class="settings-section" data-section="behavior">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
BEHAVIOR
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Open links in new tab</span>
<span class="setting-desc">Open bookmarks in a new browser tab</span>
</div>
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Show bookmark descriptions</span>
<span class="setting-desc">Display saved descriptions below bookmark titles</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Hide extra bookmarks in long boards</span>
<span class="setting-desc">Automatically hides extra bookmarks in long boards</span>
</div>
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
</div>
<div class="setting-row" id="visibleCountRow">
<div class="setting-info">
<span class="setting-label">Visible bookmarks before hide</span>
<span class="setting-desc">Choose how many bookmarks are shown</span>
</div>
<select class="select-input" id="settingVisibleCount">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
</select>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Suchleiste anzeigen</span>
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
</div>
</div>
</section>
<!-- WIDGETS -->
<section class="settings-section" data-section="widgets">
<button class="settings-section-title" type="button">
@@ -184,48 +120,48 @@
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Toolbar-Position</span>
<span class="setting-desc">Widget-Toolbar links oder rechts</span>
<span class="setting-desc">Widget-Toolbar links oder rechts anzeigen</span>
</div>
<select class="select-input" id="settingToolbarPos">
<option value="right" selected>Rechts</option>
<option value="left">Links</option>
</select>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Bild-Referenz Widgets</span>
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
</div>
<label class="toggle">
<input type="checkbox" id="settingImageRef">
<span class="slider"></span>
</label>
</div>
</div>
</section>
<!-- DATA -->
<!-- DATEN & HILFE -->
<section class="settings-section" data-section="data">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
DATA
DATEN & HILFE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Export Boards</span>
<span class="setting-desc">Alle Boards als JSON sichern</span>
<span class="setting-label">Backup exportieren</span>
<span class="setting-desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
</div>
<button class="btn-small" id="btnExportJSON">Export</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Import Boards</span>
<span class="setting-label">Backup importieren</span>
<span class="setting-desc">JSON-Backup wiederherstellen</span>
</div>
<button class="btn-small" id="btnImportJSON">Import</button>
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
</div>
</div>
</section>
<!-- HELP -->
<section class="settings-section" data-section="help">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
HELP
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Onboarding wiederholen</span>
@@ -236,16 +172,30 @@
</div>
</section>
<!-- ABOUT / IMPRESSUM -->
<section class="settings-section" data-section="about">
<button class="settings-section-title" type="button">
<!-- DANGER ZONE -->
<section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button">
<span class="section-chevron"></span>
ABOUT
DANGER ZONE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Alles zurücksetzen</span>
<span class="setting-desc">Löscht alle Boards, Notes und Einstellungen</span>
</div>
<button class="btn-danger" id="btnResetAll">Reset</button>
</div>
</div>
</section>
</div>
<!-- ABOUT — fixiert am unteren Rand -->
<div class="panel-footer">
<div class="about-block">
<div class="about-logo">⬡ HELLION NEWTAB</div>
<div class="about-version">Version 1.5.2 · by Hellion Online Media</div>
<div class="about-version">Version 1.9.0 · by Hellion Online Media</div>
<div class="about-links">
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
@@ -309,33 +259,13 @@
</div>
</div>
</div>
</section>
<!-- DANGER ZONE -->
<section class="settings-section" data-section="danger">
<button class="settings-section-title danger" type="button">
<span class="section-chevron"></span>
DANGER ZONE
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Reset all data</span>
<span class="setting-desc">Deletes all boards and bookmarks</span>
</div>
<button class="btn-danger" id="btnResetAll">Reset</button>
</div>
</div>
</section>
</div>
</aside>
<!-- THEME PICKER MODAL -->
<div class="modal-overlay" id="themeOverlay">
<div class="theme-modal" id="themeModal">
<div class="modal-header">
<span>Theme wählen</span>
<span>Darstellung</span>
<button class="btn-close" id="btnCloseTheme"></button>
</div>
<div class="theme-grid">
@@ -381,27 +311,83 @@
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title">BACKGROUND</h3>
<h3 class="settings-section-title">HINTERGRUND</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Background image URL</span>
<span class="setting-desc">Custom wallpaper URL</span>
<span class="setting-label">Bild-URL</span>
<span class="setting-desc">Eigenes Hintergrundbild per URL</span>
</div>
<button class="btn-small" id="btnChangeBg">Change</button>
<button class="btn-small" id="btnChangeBg">Ändern</button>
</div>
<div class="setting-row hidden" id="bgInputRow">
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... or leave empty for default" />
<button class="btn-small" id="btnApplyBg">Apply</button>
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
<button class="btn-small" id="btnApplyBg">Übernehmen</button>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Background file upload</span>
<span class="setting-desc">Use a local image as background</span>
<span class="setting-label">Datei hochladen</span>
<span class="setting-desc">Lokales Bild als Hintergrund verwenden</span>
</div>
<button class="btn-small" id="btnBgFile">Upload</button>
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
</div>
</div>
<div class="theme-modal-section">
<h3 class="settings-section-title">DARSTELLUNG</h3>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Kompaktmodus</span>
<span class="setting-desc">Weniger Abstand für mehr Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Lange Titel kürzen</span>
<span class="setting-desc">Titel auf eine Zeile mit „…" kürzen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShorten" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Suchleiste anzeigen</span>
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Links in neuem Tab</span>
<span class="setting-desc">Bookmarks in neuem Browser-Tab öffnen</span>
</div>
<label class="toggle"><input type="checkbox" id="settingNewTab" checked /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Beschreibungen anzeigen</span>
<span class="setting-desc">Gespeicherte Beschreibung unter Bookmarks</span>
</div>
<label class="toggle"><input type="checkbox" id="settingShowDesc" /><span class="slider"></span></label>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Bookmarks ausblenden</span>
<span class="setting-desc">Überzählige Bookmarks in langen Boards verstecken</span>
</div>
<label class="toggle"><input type="checkbox" id="settingHideExtra" /><span class="slider"></span></label>
</div>
<div class="setting-row" id="visibleCountRow">
<div class="setting-info">
<span class="setting-label">Sichtbare Bookmarks</span>
<span class="setting-desc">Anzahl vor dem Ausblenden</span>
</div>
<select class="select-input" id="settingVisibleCount">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
</select>
</div>
</div>
</div>
</div>
@@ -475,6 +461,9 @@
<script src="src/js/search.js"></script>
<script src="src/js/widgets.js"></script>
<script src="src/js/notes.js"></script>
<script src="src/js/calculator.js"></script>
<script src="src/js/timer.js"></script>
<script src="src/js/image-ref.js"></script>
<script src="src/js/data.js"></script>
<!-- Onboarding -->
<script src="src/js/onboarding.js"></script>
+406 -4
View File
@@ -537,7 +537,13 @@ body.show-desc .bm-desc { display: block; }
font-family: var(--font-display); font-size: 15px; font-weight: 600;
letter-spacing: 2px; color: var(--accent); text-transform: uppercase;
}
.panel-body { flex: 1; overflow-y: auto; padding: 12px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.panel-body { flex: 1; overflow-y: auto; padding: 12px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; min-height: 0; }
.panel-footer {
flex-shrink: 0; border-top: 1px solid var(--border);
max-height: 45vh; overflow-y: auto;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.panel-footer .about-block { padding: 12px 18px 16px; }
.settings-section { margin-bottom: 4px; }
.settings-section-title {
@@ -639,11 +645,12 @@ body.show-desc .bm-desc { display: block; }
/* INPUTS */
.select-input {
padding: 5px 8px; background: rgba(255,255,255,0.06);
padding: 5px 8px; background: var(--bg-board, rgba(255,255,255,0.06));
border: 1px solid var(--border); border-radius: var(--radius-sm);
color: var(--text-primary); font-family: var(--font-body); font-size: 12px;
cursor: pointer; min-width: 70px;
}
.select-input option { background: var(--bg-primary, #0a0e17); color: var(--text-primary); }
.select-input:focus { outline: none; border-color: var(--border-accent); }
.text-input {
@@ -908,7 +915,7 @@ body.show-desc .bm-desc { display: block; }
============================================ */
.widget {
position: fixed;
z-index: 51;
z-index: 100;
min-width: 200px; min-height: 150px;
background: var(--bg-board);
border: 1px solid var(--border-accent);
@@ -1056,6 +1063,399 @@ body.show-desc .bm-desc { display: block; }
opacity: 0.5;
}
/* ============================================
CALCULATOR WIDGET
============================================ */
.calc-display {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-bottom: 8px;
text-align: right;
min-height: 48px;
}
.calc-expression {
font-size: 12px;
color: var(--text-muted);
min-height: 16px;
word-break: break-all;
}
.calc-result {
font-size: 22px;
font-family: 'Rajdhani', monospace;
color: var(--text-primary);
font-weight: 600;
word-break: break-all;
}
.calc-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.calc-btn {
height: 36px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calc-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--border-accent);
}
.calc-btn:active {
transform: scale(0.95);
}
.calc-btn.operator {
color: var(--accent);
font-weight: 600;
}
.calc-btn.equals {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
.calc-btn.equals:hover {
background: var(--accent);
color: var(--bg-primary);
}
.calc-btn.clear {
color: var(--danger);
}
.calc-history {
border-top: 1px solid var(--border);
padding-top: 6px;
max-height: 100px;
overflow-y: auto;
}
.calc-history-title {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.calc-history-item {
font-size: 11px;
color: var(--text-secondary);
padding: 3px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
justify-content: space-between;
}
.calc-history-item:hover {
background: rgba(255,255,255,0.04);
}
.calc-history-item .calc-h-result {
color: var(--accent);
font-weight: 600;
}
/* ============================================
TIMER WIDGET
============================================ */
.timer-display {
text-align: center;
padding: 16px 0 8px;
}
.timer-time {
font-size: 36px;
font-family: 'Rajdhani', monospace;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 2px;
}
.timer-time.finished {
color: var(--danger);
animation: timer-pulse 1s ease-in-out infinite;
}
@keyframes timer-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.timer-input-row {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.timer-input {
width: 120px;
text-align: center;
font-family: 'Rajdhani', monospace;
font-size: 16px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
}
.timer-input::placeholder { color: var(--text-muted); }
.timer-input:focus {
outline: none;
border-color: var(--accent);
}
.timer-controls {
display: flex;
gap: 6px;
margin-bottom: 12px;
padding: 0 8px;
}
.timer-ctrl-btn {
flex: 1;
height: 32px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.timer-ctrl-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--border-accent);
}
.timer-ctrl-btn:active { transform: scale(0.95); }
.timer-ctrl-btn:disabled {
opacity: 0.3;
cursor: default;
transform: none;
}
.timer-ctrl-btn.primary {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.timer-ctrl-btn.primary:hover {
background: var(--accent);
color: var(--bg-primary);
}
.timer-ctrl-btn.danger {
color: var(--danger);
}
.timer-mute-btn {
width: 32px;
height: 32px;
flex: none;
font-size: 14px;
line-height: 1;
padding: 0;
background: var(--bg-board);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.timer-mute-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.timer-mute-btn.muted {
color: var(--text-muted);
opacity: 0.5;
}
.timer-presets {
border-top: 1px solid var(--border);
padding: 8px 8px 0;
max-height: 140px;
overflow-y: auto;
}
.timer-presets-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.timer-presets-title {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.timer-preset-add {
width: 22px; height: 22px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.timer-preset-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.timer-preset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
}
.timer-preset-item:hover {
background: rgba(255,255,255,0.04);
}
.timer-preset-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-preset-time {
color: var(--accent);
font-family: 'Rajdhani', monospace;
margin: 0 8px;
}
.timer-preset-del {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
padding: 2px 4px;
}
.timer-preset-del:hover { color: var(--danger); }
.timer-add-row {
display: flex;
gap: 4px;
margin-top: 6px;
}
.timer-add-input {
flex: 1;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 11px;
padding: 4px 6px;
}
.timer-add-input::placeholder { color: var(--text-muted); }
.timer-add-input:focus { outline: none; border-color: var(--accent); }
.timer-add-confirm {
background: var(--accent-dim);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: var(--accent);
cursor: pointer;
font-size: 11px;
padding: 4px 8px;
}
.timer-add-confirm:hover {
background: var(--accent);
color: var(--bg-primary);
}
/* ============================================
IMAGE REFERENCE WIDGET
============================================ */
.imgref-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px;
}
.imgref-img-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-sm);
min-height: 80px;
}
.imgref-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius-sm);
}
.imgref-dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
min-height: 80px;
gap: 8px;
}
.imgref-dropzone:hover,
.imgref-dropzone.dragover {
border-color: var(--accent);
color: var(--text-secondary);
}
.imgref-dropzone-icon {
font-size: 24px;
opacity: 0.5;
}
.imgref-label {
width: 100%;
margin-top: 8px;
font-size: 11px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
padding: 4px 8px;
flex-shrink: 0;
}
.imgref-label:focus {
border-color: var(--accent);
outline: none;
}
.imgref-label::placeholder {
color: var(--text-muted);
}
.imgref-replace-btn {
width: 100%;
height: 24px;
margin-top: 6px;
font-size: 10px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
}
.imgref-replace-btn:hover {
border-color: var(--accent);
color: var(--text-secondary);
}
/* ============================================
WIDGET TOOLBAR
============================================ */
@@ -1064,7 +1464,7 @@ body.show-desc .bm-desc { display: block; }
right: 16px;
top: 50%;
transform: translateY(-50%);
z-index: 90;
z-index: 100;
display: flex;
flex-direction: column;
gap: 8px;
@@ -1492,6 +1892,7 @@ body.show-desc .bm-desc { display: block; }
.search-bar { max-width: 400px; }
.widget-toolbar-btn { width: 32px; height: 32px; }
.notebook-panel { width: 320px; }
.calc-btn { height: 32px; font-size: 13px; }
}
/* Smartphone (max 480px) */
@@ -1528,6 +1929,7 @@ body.show-desc .bm-desc { display: block; }
.toolbar-left .widget-toolbar { left: 50%; right: auto; transform: translateX(-50%); }
.widget-toolbar-btn { width: 32px; height: 32px; }
.notebook-panel { width: 100%; right: -100%; }
.calc-btn { height: 30px; font-size: 12px; }
.toolbar-left .notebook-panel { left: -100%; }
.modal { width: calc(100vw - 32px); }
+6 -1
View File
@@ -18,6 +18,9 @@ async function init() {
initSearch();
await migrateSticky();
await Notes.init();
await Calculator.init();
await Timer.init();
await ImageRef.init();
initDataButtons();
Store.checkQuota();
@@ -98,7 +101,9 @@ async function checkBackupReminder() {
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
const widgetData = await Store.get('widgetStates');
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
const data = { version: '1.6.0', exported: new Date().toISOString(), boards, settings, notes: notesData };
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
const data = { version: '1.9.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 url = URL.createObjectURL(blob);
const a = document.createElement('a');
+729
View File
@@ -0,0 +1,729 @@
/* =============================================
HELLION NEWTAB — calculator.js
Taschenrechner Widget: Expression-Parsing,
History, Tastatureingabe
============================================= */
const Calculator = {
WIDGET_ID: 'widget_calculator',
STORAGE_KEY: 'widgetStates',
MAX_HISTORY: 10,
/** @type {Array<{expr: string, result: string}>} */
_history: [],
_currentExpr: '',
_lastResult: '',
_isOpen: false,
_displayExprEl: null,
_displayResultEl: null,
_keydownHandler: null,
// ---- STORAGE ----
/**
* Calculator-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator) {
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
}
},
/**
* Calculator-State in Storage speichern
* Bestehende Notes-Daten bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
const notesState = Array.isArray(data.notes) ? data.notes : [];
// Widget-Position aus WidgetManager holen
const widgetState = WidgetManager.getState(this.WIDGET_ID);
const calcData = {
x: widgetState ? widgetState.x : 400,
y: widgetState ? widgetState.y : 120,
width: widgetState ? widgetState.width : 280,
height: widgetState ? widgetState.height : 400,
open: this._isOpen,
history: this._history.slice(0, this.MAX_HISTORY)
};
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
},
// ---- WIDGET LIFECYCLE ----
/**
* Calculator oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
// Gespeicherte Position laden
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.calculator) ? data.calculator : {};
const widgetId = WidgetManager.create('calculator', {
id: this.WIDGET_ID,
title: 'Taschenrechner',
x: saved.x || 400,
y: saved.y || 120,
width: saved.width || 280,
height: saved.height || 400,
open: true
});
const body = WidgetManager.getBody(widgetId);
if (body) this.renderBody(body);
this._isOpen = true;
// Keyboard-Events binden
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Calculator toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
this._isOpen = false;
this._unbindKeyboard();
this._displayExprEl = null;
this._displayResultEl = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Calculator-Body rendern (in Widget-Body einfuegen)
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
// Display
const display = document.createElement('div');
display.className = 'calc-display';
const exprEl = document.createElement('div');
exprEl.className = 'calc-expression';
this._displayExprEl = exprEl;
const resultEl = document.createElement('div');
resultEl.className = 'calc-result';
resultEl.textContent = '0';
this._displayResultEl = resultEl;
display.append(exprEl, resultEl);
// Buttons
const buttonsEl = this._createButtons();
// History
const historyEl = this._createHistoryPanel();
bodyEl.append(display, buttonsEl, historyEl);
// Aktuellen State anzeigen
this._updateDisplay();
},
/**
* Button-Grid erstellen (4x5)
* @returns {HTMLElement}
*/
_createButtons() {
const grid = document.createElement('div');
grid.className = 'calc-buttons';
// Button-Layout: [label, value, cssClass]
const buttons = [
['C', 'clear', 'clear'],
['()', 'paren', 'operator'],
['%', '%', 'operator'],
['\u00F7', '/', 'operator'],
['7', '7', ''],
['8', '8', ''],
['9', '9', ''],
['\u00D7', '*', 'operator'],
['4', '4', ''],
['5', '5', ''],
['6', '6', ''],
['\u2212', '-', 'operator'],
['1', '1', ''],
['2', '2', ''],
['3', '3', ''],
['+', '+', 'operator'],
['0', '0', ''],
['.', '.', ''],
['\u232B', 'backspace', ''],
['=', '=', 'equals']
];
buttons.forEach(([label, value, cls]) => {
const btn = document.createElement('button');
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.type = 'button';
btn.addEventListener('click', () => this._handleKey(value));
grid.appendChild(btn);
});
return grid;
},
/**
* History-Panel erstellen
* @returns {HTMLElement}
*/
_createHistoryPanel() {
const container = document.createElement('div');
container.className = 'calc-history';
container.id = 'calcHistoryPanel';
const title = document.createElement('div');
title.className = 'calc-history-title';
title.textContent = 'History';
container.appendChild(title);
this._renderHistoryItems(container);
return container;
},
/**
* History-Items rendern
* @param {HTMLElement} container
*/
_renderHistoryItems(container) {
// Alte Items entfernen (nur die .calc-history-item Elemente)
const oldItems = container.querySelectorAll('.calc-history-item');
oldItems.forEach(item => item.remove());
if (this._history.length === 0) return;
// Neueste zuerst
const reversed = [...this._history].reverse();
reversed.forEach(entry => {
const item = document.createElement('div');
item.className = 'calc-history-item';
const exprSpan = document.createElement('span');
exprSpan.textContent = entry.expr;
const resultSpan = document.createElement('span');
resultSpan.className = 'calc-h-result';
resultSpan.textContent = '= ' + entry.result;
item.append(exprSpan, resultSpan);
// Klick uebernimmt Ergebnis als neue Eingabe
item.addEventListener('click', () => {
this._currentExpr = entry.result;
this._lastResult = '';
this._updateDisplay();
});
container.appendChild(item);
});
},
// ---- INPUT HANDLING ----
/**
* Taste verarbeiten
* @param {string} key
*/
_handleKey(key) {
switch (key) {
case 'clear':
this._currentExpr = '';
this._lastResult = '';
break;
case 'backspace':
this._currentExpr = this._currentExpr.slice(0, -1);
break;
case '=':
this._calculate();
return;
case 'paren': {
// Smarte Klammern: oeffnende wenn noetig, sonst schliessende
const openCount = (this._currentExpr.match(/\(/g) || []).length;
const closeCount = (this._currentExpr.match(/\)/g) || []).length;
const lastChar = this._currentExpr.slice(-1);
if (openCount <= closeCount || /[+\-*/%(]$/.test(lastChar) || this._currentExpr === '') {
this._currentExpr += '(';
} else {
this._currentExpr += ')';
}
break;
}
case '%':
case '+':
case '-':
case '*':
case '/': {
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
if (this._lastResult && this._currentExpr === '') {
this._currentExpr = this._lastResult;
this._lastResult = '';
}
// Doppelte Operatoren verhindern (letzten ersetzen)
const last = this._currentExpr.slice(-1);
if (/[+\-*/%]/.test(last)) {
this._currentExpr = this._currentExpr.slice(0, -1) + key;
} else {
this._currentExpr += key;
}
break;
}
case '.': {
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
const parts = this._currentExpr.split(/[+\-*/%()]/);
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.includes('.')) break;
this._currentExpr += key;
break;
}
default:
// Ziffern 0-9
if (/^[0-9]$/.test(key)) {
// Wenn ein Ergebnis da ist und User eine Zahl tippt, neue Berechnung starten
if (this._lastResult && this._currentExpr === '') {
this._lastResult = '';
}
this._currentExpr += key;
}
break;
}
this._updateDisplay();
},
/**
* Berechnung ausfuehren
*/
async _calculate() {
if (!this._currentExpr) return;
const result = this._evaluate(this._currentExpr);
if (result === null) {
this._lastResult = 'Fehler';
this._updateDisplay();
return;
}
const resultStr = this._formatResult(result);
this._addHistory(this._currentExpr, resultStr);
this._lastResult = resultStr;
// Display aktualisieren
if (this._displayExprEl) {
this._displayExprEl.textContent = this._formatExpression(this._currentExpr) + ' =';
}
if (this._displayResultEl) {
this._displayResultEl.textContent = resultStr;
}
this._currentExpr = '';
// History-Panel aktualisieren
const historyPanel = document.getElementById('calcHistoryPanel');
if (historyPanel) this._renderHistoryItems(historyPanel);
await this.save();
},
// ---- EXPRESSION PARSER (Shunting-Yard, KEIN eval!) ----
/**
* Expression sicher auswerten
* @param {string} expr
* @returns {number|null}
*/
_evaluate(expr) {
try {
// Nur erlaubte Zeichen
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
if (!sanitized) return null;
const tokens = this._tokenize(sanitized);
if (!tokens) return null;
return this._parseExpression(tokens);
} catch {
return null;
}
},
/**
* Expression in Tokens aufteilen
* @param {string} expr
* @returns {Array|null}
*/
_tokenize(expr) {
const tokens = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i];
// Zahl (inkl. Dezimal)
if (/[0-9.]/.test(ch)) {
let num = '';
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
// Operator
if (/[+\-*/%]/.test(ch)) {
// Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
if (ch === '-') {
const prev = tokens[tokens.length - 1];
if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
// Negatives Vorzeichen → als Teil der naechsten Zahl lesen
let num = '-';
i++;
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
if (num === '-') return null;
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
}
tokens.push({ type: 'op', value: ch });
i++;
continue;
}
// Klammern
if (ch === '(' || ch === ')') {
tokens.push({ type: 'paren', value: ch });
i++;
continue;
}
// Unbekanntes Zeichen
return null;
}
return tokens;
},
/**
* Rekursiver Descent Parser mit Operator-Precedence
* @param {Array} tokens
* @returns {number|null}
*/
_parseExpression(tokens) {
let pos = 0;
function peek() { return tokens[pos]; }
function consume() { return tokens[pos++]; }
// Expression: Term (('+' | '-') Term)*
function parseExpr() {
let left = parseTerm();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
consume();
const right = parseTerm();
if (right === null) return null;
left = t.value === '+' ? left + right : left - right;
}
return left;
}
// Term: Factor (('*' | '/' | '%') Factor)*
function parseTerm() {
let left = parseFactor();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
consume();
const right = parseFactor();
if (right === null) return null;
if (t.value === '*') {
left = left * right;
} else if (t.value === '/') {
if (right === 0) return null;
left = left / right;
} else {
left = left % right;
}
}
return left;
}
// Factor: Number | '(' Expression ')'
function parseFactor() {
const t = peek();
if (!t) return null;
if (t.type === 'number') {
consume();
return t.value;
}
if (t.type === 'paren' && t.value === '(') {
consume();
const val = parseExpr();
if (val === null) return null;
const closing = peek();
if (closing && closing.type === 'paren' && closing.value === ')') {
consume();
}
return val;
}
return null;
}
const result = parseExpr();
// Alle Tokens muessen verbraucht sein
if (pos < tokens.length) return null;
if (result === null || !isFinite(result)) return null;
return result;
},
// ---- FORMATTING ----
/**
* Ergebnis formatieren (maximal 10 Dezimalstellen, trailing Nullen entfernen)
* @param {number} num
* @returns {string}
*/
_formatResult(num) {
if (Number.isInteger(num)) return num.toString();
// Maximal 10 Dezimalstellen, trailing Nullen weg
const str = num.toFixed(10).replace(/\.?0+$/, '');
return str;
},
/**
* Expression fuer Anzeige formatieren (× statt *, ÷ statt /)
* @param {string} expr
* @returns {string}
*/
_formatExpression(expr) {
return expr
.replace(/\*/g, '\u00D7')
.replace(/\//g, '\u00F7');
},
// ---- DISPLAY ----
/**
* Display aktualisieren
*/
_updateDisplay() {
if (this._displayExprEl) {
if (this._lastResult) {
// Ergebnis-Modus: Expression oben, Ergebnis gross
// (wird von _calculate() direkt gesetzt)
} else {
this._displayExprEl.textContent = '';
}
}
if (this._displayResultEl) {
if (this._lastResult && this._currentExpr === '') {
this._displayResultEl.textContent = this._lastResult;
} else {
this._displayResultEl.textContent = this._formatExpression(this._currentExpr) || '0';
}
}
},
// ---- HISTORY ----
/**
* History-Eintrag hinzufuegen
* @param {string} expr
* @param {string} result
*/
_addHistory(expr, result) {
this._history.push({
expr: this._formatExpression(expr),
result: result
});
// Limit einhalten
if (this._history.length > this.MAX_HISTORY) {
this._history = this._history.slice(-this.MAX_HISTORY);
}
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nur reagieren wenn Calculator-Widget fokussiert ist
// (d.h. nicht wenn User in Textarea/Input tippt)
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
if (e.target.contentEditable === 'true') return;
const key = e.key;
let handled = false;
if (/^[0-9]$/.test(key)) {
this._handleKey(key);
handled = true;
} else if (key === '+' || key === '-' || key === '*' || key === '/') {
this._handleKey(key);
handled = true;
} else if (key === '.') {
this._handleKey('.');
handled = true;
} else if (key === '%') {
this._handleKey('%');
handled = true;
} else if (key === '(' || key === ')') {
this._handleKey('paren');
handled = true;
} else if (key === 'Enter' || key === '=') {
this._handleKey('=');
handled = true;
} else if (key === 'Backspace') {
this._handleKey('backspace');
handled = true;
} else if (key === 'Escape' || key === 'c' || key === 'C') {
this._handleKey('clear');
handled = true;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
// Widget fokussierbar machen
widgetEl.tabIndex = 0;
widgetEl.focus();
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Calculator initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator && data.calculator.open) {
await this.open();
}
// Close-Event abfangen: WidgetManager.close() ueberschreiben
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
WidgetManager.close = function(id) {
origClose(id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
WidgetManager.minimize = async function(id) {
await origMinimize(id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Open-Event abfangen
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
WidgetManager.openWidget = async function(id) {
await origOpen(id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
// Body neu rendern (war durch minimize entfernt)
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);
await self.save();
}
};
}
};
+41 -6
View File
@@ -13,11 +13,13 @@ function initDataButtons() {
btnExport.addEventListener('click', async () => {
const widgetData = await Store.get('widgetStates');
const data = {
version: '1.6.0',
version: '1.9.0',
exported: new Date().toISOString(),
boards,
settings,
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : []
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@@ -60,9 +62,9 @@ function initDataButtons() {
// Notes importieren (falls vorhanden)
let notesImported = 0;
const existingWidgets = await Store.get('widgetStates') || {};
if (Array.isArray(data.notes) && data.notes.length > 0) {
const existingWidgets = await Store.get('widgetStates');
const existingNotes = (existingWidgets && Array.isArray(existingWidgets.notes)) ? existingWidgets.notes : [];
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
const importNotes = data.notes.filter(n => {
if (!n || !n.id || !n.template) return false;
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
@@ -73,15 +75,48 @@ function initDataButtons() {
const toImport = importNotes.slice(0, spaceLeft);
if (toImport.length > 0) {
const merged = [...existingNotes, ...toImport];
await Store.set('widgetStates', { notes: merged });
existingWidgets.notes = merged;
Notes._notes = merged;
notesImported = toImport.length;
}
}
// Calculator-History importieren (falls vorhanden)
let calcImported = false;
if (Array.isArray(data.calculator) && data.calculator.length > 0) {
const calcHistory = data.calculator.filter(h => h && typeof h.expr === 'string' && typeof h.result === 'string');
if (calcHistory.length > 0) {
if (!existingWidgets.calculator) {
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
}
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
Calculator._history = existingWidgets.calculator.history;
calcImported = true;
}
}
// Timer-Presets importieren (falls vorhanden)
let timerImported = false;
if (Array.isArray(data.timerPresets) && data.timerPresets.length > 0) {
const validPresets = data.timerPresets.filter(p => p && typeof p.name === 'string' && typeof p.seconds === 'number');
if (validPresets.length > 0) {
if (!existingWidgets.timer) {
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
}
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
Timer._presets = existingWidgets.timer.presets;
timerImported = true;
}
}
// Gemeinsam speichern
await Store.set('widgetStates', existingWidgets);
const noteMsg = notesImported > 0 ? ` + ${notesImported} Note(s)` : '';
const calcMsg = calcImported ? ' + Calculator-History' : '';
const timerMsg = timerImported ? ' + Timer-Presets' : '';
await HellionDialog.alert(
`${validBoards.length} Board(s)${noteMsg} erfolgreich importiert.`,
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
{ type: 'success', title: 'Import erfolgreich' }
);
} catch (err) {
+500
View File
@@ -0,0 +1,500 @@
/* =============================================
HELLION NEWTAB — image-ref.js
Bild-Referenz Widget: Session-only Bildanzeige
mit Canvas API WebP-Konvertierung
============================================= */
const ImageRef = {
MAX_IMAGES: 3,
STORAGE_KEY: 'widgetStates',
SESSION_KEY: 'imageRefData',
/** @type {Array<{id: string, label: string, x: number, y: number, width: number, height: number, open: boolean}>} */
_images: [],
_saveTimer: null,
// ---- STORAGE (persistent: Position/Meta) ----
/**
* Widget-Meta aus persistentem Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.imageRef && Array.isArray(data.imageRef.images)) {
this._images = data.imageRef.images;
}
},
/**
* Widget-Meta persistent speichern
* Bestehende Notes, Calculator, Timer bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
// Positionen aus WidgetManager aktualisieren
const updated = this._images.map(img => {
const ws = WidgetManager.getState(img.id);
if (ws) {
img.x = ws.x;
img.y = ws.y;
img.width = ws.width;
img.height = ws.height;
img.open = ws.open;
}
return img;
});
data.imageRef = { images: updated };
await Store.set(this.STORAGE_KEY, data);
},
/**
* Debounced Save
*/
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
},
// ---- SESSION STORAGE (Bilddaten) ----
/**
* Bilddaten in sessionStorage speichern
*/
_saveSession() {
try {
const sessionData = {};
this._images.forEach(img => {
const dataUrl = this._getSessionImage(img.id);
if (dataUrl) sessionData[img.id] = dataUrl;
});
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
}
},
/**
* Bilddaten aus sessionStorage laden
* @returns {Object} - { id: dataUrl, ... }
*/
_loadSessionAll() {
try {
const raw = sessionStorage.getItem(this.SESSION_KEY);
if (!raw) return {};
return JSON.parse(raw);
} catch (e) {
console.warn('ImageRef: sessionStorage Read fehlgeschlagen', e);
return {};
}
},
/**
* Einzelnes Bild aus sessionStorage lesen
* @param {string} id
* @returns {string|null}
*/
_getSessionImage(id) {
const all = this._loadSessionAll();
return all[id] || null;
},
/**
* Einzelnes Bild in sessionStorage setzen
* @param {string} id
* @param {string} dataUrl
*/
_setSessionImage(id, dataUrl) {
try {
const all = this._loadSessionAll();
all[id] = dataUrl;
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
HellionDialog.alert(
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
{ type: 'danger', title: 'Speicherfehler' }
);
}
},
/**
* Einzelnes Bild aus sessionStorage entfernen
* @param {string} id
*/
_removeSessionImage(id) {
try {
const all = this._loadSessionAll();
delete all[id];
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Remove fehlgeschlagen', e);
}
},
// ---- WIDGET LIFECYCLE ----
/**
* Neues Bild-Widget erstellen (oeffnet File-Dialog)
*/
async create() {
if (!settings.imageRefEnabled) return;
if (this._images.length >= this.MAX_IMAGES) {
await HellionDialog.alert(
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
{ type: 'warning', title: 'Limit erreicht' }
);
return;
}
// Freie ID finden
const usedIds = new Set(this._images.map(i => i.id));
let slotId = null;
for (let i = 0; i < this.MAX_IMAGES; i++) {
const candidate = 'image_' + i;
if (!usedIds.has(candidate)) {
slotId = candidate;
break;
}
}
if (!slotId) return;
// File-Dialog
const file = await this._pickFile();
if (!file) return;
// Bild verarbeiten
let dataUrl;
try {
dataUrl = await this._processFile(file);
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
return;
}
// In sessionStorage speichern
this._setSessionImage(slotId, dataUrl);
// Meta erstellen
const imageData = {
id: slotId,
label: '',
x: 200 + (this._images.length * 40),
y: 120 + (this._images.length * 30),
width: 320,
height: 280,
open: true
};
this._images.push(imageData);
// Widget erstellen
this._createWidget(imageData, dataUrl);
await this.save();
},
/**
* Widget im DOM erstellen
* @param {Object} imageData
* @param {string|null} dataUrl
*/
_createWidget(imageData, dataUrl) {
WidgetManager.create('image', {
id: imageData.id,
title: imageData.label || 'Bild-Referenz',
x: imageData.x,
y: imageData.y,
width: imageData.width,
height: imageData.height,
open: imageData.open !== false
});
const body = WidgetManager.getBody(imageData.id);
if (body) this.renderBody(imageData, body, dataUrl);
},
/**
* Widget geschlossen — Daten aufraeumen
* @param {string} id
*/
async onClose(id) {
this._removeSessionImage(id);
this._images = this._images.filter(img => img.id !== id);
await this.save();
},
// ---- UI RENDERING ----
/**
* Widget-Body rendern
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @param {string|null} dataUrl
*/
renderBody(imageData, bodyEl, dataUrl) {
bodyEl.textContent = '';
const container = document.createElement('div');
container.className = 'imgref-container';
if (dataUrl) {
// Bild anzeigen
const wrapper = document.createElement('div');
wrapper.className = 'imgref-img-wrapper';
const img = document.createElement('img');
img.className = 'imgref-img';
img.src = dataUrl;
img.alt = imageData.label || 'Bild-Referenz';
wrapper.appendChild(img);
// Bild ersetzen Button
const replaceBtn = document.createElement('button');
replaceBtn.className = 'imgref-replace-btn';
replaceBtn.type = 'button';
replaceBtn.textContent = 'Bild ersetzen';
replaceBtn.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const newDataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, newDataUrl);
this.renderBody(imageData, bodyEl, newDataUrl);
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
container.append(wrapper, replaceBtn);
} else {
// Drop-Zone (kein Bild vorhanden)
const dropzone = this._createDropzone(imageData, bodyEl);
container.appendChild(dropzone);
}
// Label-Input
const label = document.createElement('input');
label.className = 'imgref-label';
label.type = 'text';
label.placeholder = 'Beschriftung (optional)';
label.maxLength = 100;
label.value = imageData.label || '';
label.addEventListener('input', () => {
const text = label.value.trim().slice(0, 100);
imageData.label = text;
// Widget-Titel aktualisieren
const entry = WidgetManager._widgets.get(imageData.id);
if (entry) {
const titleEl = entry.el.querySelector('.widget-title-text');
if (titleEl) titleEl.textContent = text || 'Bild-Referenz';
entry.state.title = text || 'Bild-Referenz';
}
this._debouncedSave();
});
container.appendChild(label);
bodyEl.appendChild(container);
},
/**
* Drop-Zone erstellen (fuer leere Widgets / neue Bilder)
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @returns {HTMLElement}
*/
_createDropzone(imageData, bodyEl) {
const dropzone = document.createElement('div');
dropzone.className = 'imgref-dropzone';
const icon = document.createElement('div');
icon.className = 'imgref-dropzone-icon';
icon.textContent = '\uD83D\uDDBC\uFE0F';
const text = document.createElement('span');
text.textContent = 'Klicken oder Bild hierher ziehen';
dropzone.append(icon, text);
// Klick -> File-Dialog
dropzone.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
// Drag & Drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) {
await HellionDialog.alert(
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
{ type: 'warning', title: 'Kein Bild' }
);
return;
}
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
return dropzone;
},
// ---- FILE HANDLING ----
/**
* File-Dialog oeffnen
* @returns {Promise<File|null>}
*/
_pickFile() {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
resolve(input.files[0] || null);
});
// Cancel erkennen
input.addEventListener('cancel', () => resolve(null));
input.click();
});
},
/**
* Bild per Canvas API zu WebP konvertieren
* @param {File} file
* @returns {Promise<string>} WebP DataURL
*/
_processFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const webpUrl = canvas.toDataURL('image/webp', 0.85);
URL.revokeObjectURL(objectUrl);
resolve(webpUrl);
} catch (err) {
URL.revokeObjectURL(objectUrl);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Bild konnte nicht geladen werden'));
};
img.src = objectUrl;
});
},
// ---- INIT ----
/**
* ImageRef initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Widgets wiederherstellen (nur wenn Feature aktiviert)
if (settings.imageRefEnabled && this._images.length > 0) {
const sessionData = this._loadSessionAll();
this._images.forEach(imageData => {
if (imageData.open !== false) {
const dataUrl = sessionData[imageData.id] || null;
this._createWidget(imageData, dataUrl);
}
});
}
// Close-Event abfangen
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
// Pruefen ob es ein Image-Widget ist
const isImage = self._images.some(img => img.id === id);
if (isImage) {
self.onClose(id);
}
};
// Minimize-Event abfangen
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
const isImage = self._images.some(img => img.id === id);
if (isImage) {
await self.save();
}
};
// Open-Event abfangen
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
const imgData = self._images.find(img => img.id === id);
if (imgData) {
const body = WidgetManager.getBody(id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(id);
self.renderBody(imgData, body, dataUrl);
}
await self.save();
}
};
}
};
+19 -1
View File
@@ -44,7 +44,19 @@ const Notes = {
return note;
});
await Store.set(this.STORAGE_KEY, { notes: merged });
// Calculator- und Timer-State beibehalten falls vorhanden
const existing = await Store.get(this.STORAGE_KEY);
const saveData = { notes: merged };
if (existing && existing.calculator) {
saveData.calculator = existing.calculator;
}
if (existing && existing.timer) {
saveData.timer = existing.timer;
}
if (existing && existing.imageRef) {
saveData.imageRef = existing.imageRef;
}
await Store.set(this.STORAGE_KEY, saveData);
},
/**
@@ -514,6 +526,12 @@ const Notes = {
await this.create('text');
} else if (action === 'new-checklist') {
await this.create('checklist');
} else if (action === 'calculator') {
Calculator.toggle();
} else if (action === 'timer') {
Timer.toggle();
} else if (action === 'image-ref') {
ImageRef.create();
} else if (action === 'notebook') {
this.openNotebook();
}
+65 -9
View File
@@ -25,17 +25,19 @@ const Onboarding = {
{
hero: '\uD83C\uDFA8',
title: '8 handgefertigte Themes',
text: 'Klicke auf den „Theme" Button im Header um dein Theme zu wählen. Jedes hat seinen eigenen Stil und Farbpalette.',
text: 'Klicke auf den \u201ETheme\u201C Button im Header um dein Theme zu w\u00E4hlen. Jedes hat seinen eigenen Stil und Farbpalette.',
showThemes: true
},
{
hero: '\u26A1',
title: 'Weitere Features',
hero: '\uD83E\uDDF0',
title: 'Widget-Toolbar',
features: [
'Suchleiste mit Google, DuckDuckGo oder Bing',
'Widget-Toolbar rechts \u2014 Notes und Checklisten erstellen',
'Notebook-Sidebar \u00FCber den \u201ENote\u201C Button oder die Toolbar',
'Funktioniert komplett offline \u2014 alles lokal gespeichert'
'Die schwebenden Buttons rechts \u00F6ffnen Widgets',
'Notes und Checklisten f\u00FCr schnelle Notizen',
'Taschenrechner mit History',
'Timer/Countdown mit speicherbaren Presets',
'Bild-Referenz Widgets (aktivierbar in Settings)',
'Notebook-Sidebar zeigt alle Notes auf einen Blick'
]
},
{
@@ -43,10 +45,16 @@ const Onboarding = {
title: 'Backups nicht vergessen!',
text: 'Deine Daten sind lokal im Browser gespeichert. Wenn du Browserdaten l\u00F6schst, gehen sie verloren! Sichere regelm\u00E4\u00DFig \u00FCber Settings \u2192 Data \u2192 Export. Wir erinnern dich alle 7 Tage daran.'
},
{
hero: '\uD83C\uDFAE',
title: 'Gaming Starter Board',
text: 'Spielst du Games wie Satisfactory, Factorio oder Star Citizen? Ich kann ein Board mit n\u00FCtzlichen Community-Links anlegen.',
interactive: 'gaming-board'
},
{
hero: '\uD83D\uDE80',
title: 'Bereit!',
text: 'Klicke auf \u201E+ Board\u201C um dein erstes Board zu erstellen, oder nutze den \u201EImport\u201C Button im Header um deine Browser-Lesezeichen zu importieren.'
text: 'Erstelle dein erstes Board mit \u201E+ Board\u201C oder importiere deine Browser-Lesezeichen \u00FCber den Import-Button im Header. Viel Spa\u00DF!'
}
],
@@ -160,7 +168,27 @@ const Onboarding = {
nav.appendChild(backBtn);
}
if (isLast) {
if (slide.interactive === 'gaming-board') {
// Interaktive Slide: Zwei Buttons statt "Weiter"
const noBtn = document.createElement('button');
noBtn.className = 'btn-secondary';
noBtn.textContent = 'Nein danke';
noBtn.addEventListener('click', () => {
this.currentSlide++;
this._render();
});
const yesBtn = document.createElement('button');
yesBtn.className = 'btn-primary';
yesBtn.textContent = 'Ja, gerne';
yesBtn.addEventListener('click', async () => {
await this._createGamingBoard();
this.currentSlide++;
this._render();
});
nav.append(noBtn, yesBtn);
} else if (isLast) {
const startBtn = document.createElement('button');
startBtn.className = 'btn-primary';
startBtn.textContent = 'Los geht\u2019s!';
@@ -181,6 +209,34 @@ const Onboarding = {
modal.appendChild(footer);
},
/**
* Gaming Starter Board erstellen
* Vorbefuelltes Board mit Community-Links fuer Factory/Space Games
*/
async _createGamingBoard() {
const gamingBoard = {
id: uid(),
title: '\uD83C\uDFAE Gaming',
bookmarks: [
{ id: uid(), title: 'Satisfactory Wiki', url: 'https://satisfactory.wiki.gg', desc: '' },
{ id: uid(), title: 'Satisfactory Calculator', url: 'https://satisfactorytools.com', desc: '' },
{ id: uid(), title: 'Factorio Wiki', url: 'https://wiki.factorio.com', desc: '' },
{ id: uid(), title: 'Factorio Cheatsheet', url: 'https://factoriocheatsheet.com', desc: '' },
{ id: uid(), title: 'Avorion Wiki', url: 'https://wiki.avorion.net', desc: '' },
{ id: uid(), title: 'Minecraft Wiki', url: 'https://minecraft.wiki', 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: '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' }
],
blurred: false
};
boards.push(gamingBoard);
await saveBoards();
renderBoards();
},
/** Keyboard-Navigation */
_bindKeyboard() {
this._keyHandler = (e) => {
+15 -2
View File
@@ -25,7 +25,7 @@ function closeThemeModal() {
// ---- ACCORDION ----
function initAccordion() {
const defaultOpen = new Set(['appearance', 'behavior', 'widgets', 'data', 'help']);
const defaultOpen = new Set(['widgets']);
const sections = document.querySelectorAll('.settings-section[data-section]');
sections.forEach(section => {
@@ -71,6 +71,13 @@ function applySettings() {
const showSearchEl = document.getElementById('settingShowSearch');
if (showSearchEl) showSearchEl.checked = settings.showSearch;
// Image-Ref Toggle
if (settings.imageRefEnabled === undefined) settings.imageRefEnabled = false;
const imgRefCheckbox = document.getElementById('settingImageRef');
if (imgRefCheckbox) imgRefCheckbox.checked = settings.imageRefEnabled;
const imgRefBtn = document.querySelector('[data-action="image-ref"]');
if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled);
// Toolbar-Position
document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left');
const toolbarPosEl = document.getElementById('settingToolbarPos');
@@ -127,6 +134,11 @@ function bindSettingsEvents() {
settingShowSearch: v => {
settings.showSearch = v;
document.getElementById('searchBarWrapper').classList.toggle('hidden', !v);
},
settingImageRef: v => {
settings.imageRefEnabled = v;
const imgBtn = document.querySelector('[data-action="image-ref"]');
if (imgBtn) imgBtn.classList.toggle('hidden', !v);
}
};
@@ -204,7 +216,8 @@ function bindSettingsEvents() {
boards = [];
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
showSearch: true, searchEngine: 'google', toolbarPos: 'right' };
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
imageRefEnabled: false };
await saveBoards();
await saveSettings();
applySettings();
+2 -1
View File
@@ -16,7 +16,8 @@ let settings = {
theme: 'nebula',
showSearch: true,
searchEngine: 'google',
toolbarPos: 'right'
toolbarPos: 'right',
imageRefEnabled: false
};
function uid() {
+760
View File
@@ -0,0 +1,760 @@
/* =============================================
HELLION NEWTAB — timer.js
Timer / Countdown Widget: Presets, Alarm,
Tab-Titel-Blink
============================================= */
const Timer = {
WIDGET_ID: 'widget_timer',
STORAGE_KEY: 'widgetStates',
MAX_PRESETS: 5,
/** @type {Array<{name: string, seconds: number}>} */
_presets: [],
_isOpen: false,
_seconds: 0,
_remaining: 0,
_intervalId: null,
_running: false,
_finished: false,
_blinkIntervalId: null,
_originalTitle: '',
_keydownHandler: null,
_muted: false,
// UI-Referenzen
_timeEl: null,
_muteBtn: null,
_inputEl: null,
_inputRow: null,
_btnStart: null,
_btnPause: null,
_btnReset: null,
// ---- STORAGE ----
/**
* Timer-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer) {
this._presets = Array.isArray(data.timer.presets) ? data.timer.presets : [];
if (typeof data.timer.muted === 'boolean') this._muted = data.timer.muted;
}
},
/**
* Timer-State in Storage speichern
* Bestehende Notes + Calculator bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
const widgetState = WidgetManager.getState(this.WIDGET_ID);
data.timer = {
x: widgetState ? widgetState.x : 600,
y: widgetState ? widgetState.y : 80,
width: widgetState ? widgetState.width : 260,
height: widgetState ? widgetState.height : 360,
open: this._isOpen,
presets: this._presets.slice(0, this.MAX_PRESETS),
muted: this._muted
};
await Store.set(this.STORAGE_KEY, data);
},
// ---- WIDGET LIFECYCLE ----
/**
* Timer-Widget oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.timer) ? data.timer : {};
WidgetManager.create('timer', {
id: this.WIDGET_ID,
title: 'Timer',
x: saved.x || 600,
y: saved.y || 80,
width: saved.width || 260,
height: saved.height || 360,
open: true
});
const body = WidgetManager.getBody(this.WIDGET_ID);
if (body) this.renderBody(body);
this._isOpen = true;
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Timer toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
this._isOpen = false;
this._unbindKeyboard();
this._stopCountdown();
this._stopAlarm();
this._timeEl = null;
this._inputEl = null;
this._inputRow = null;
this._btnStart = null;
this._btnPause = null;
this._btnReset = null;
this._muteBtn = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Timer-Body rendern
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
// Display
const display = document.createElement('div');
display.className = 'timer-display';
const timeEl = document.createElement('div');
timeEl.className = 'timer-time';
timeEl.textContent = '00:00';
this._timeEl = timeEl;
display.appendChild(timeEl);
// Input
const inputRow = document.createElement('div');
inputRow.className = 'timer-input-row';
this._inputRow = inputRow;
const input = document.createElement('input');
input.className = 'timer-input';
input.type = 'text';
input.placeholder = 'mm:ss';
input.maxLength = 8;
this._inputEl = input;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._applyInput();
this._start();
}
});
inputRow.appendChild(input);
// Controls
const controls = document.createElement('div');
controls.className = 'timer-controls';
const btnStart = document.createElement('button');
btnStart.className = 'timer-ctrl-btn primary';
btnStart.type = 'button';
btnStart.textContent = 'Start';
btnStart.addEventListener('click', () => {
if (!this._running && this._remaining === 0) {
this._applyInput();
}
this._start();
});
this._btnStart = btnStart;
const btnPause = document.createElement('button');
btnPause.className = 'timer-ctrl-btn';
btnPause.type = 'button';
btnPause.textContent = 'Pause';
btnPause.disabled = true;
btnPause.addEventListener('click', () => this._pause());
this._btnPause = btnPause;
const btnReset = document.createElement('button');
btnReset.className = 'timer-ctrl-btn danger';
btnReset.type = 'button';
btnReset.textContent = 'Reset';
btnReset.addEventListener('click', () => this._reset());
this._btnReset = btnReset;
controls.append(btnStart, btnPause, btnReset);
// Mute Toggle (in Controls-Zeile)
const muteBtn = document.createElement('button');
muteBtn.className = 'timer-mute-btn';
muteBtn.type = 'button';
this._muteBtn = muteBtn;
this._updateMuteBtn();
muteBtn.addEventListener('click', async () => {
this._muted = !this._muted;
this._updateMuteBtn();
await this.save();
});
controls.appendChild(muteBtn);
// Presets
const presetsEl = this._createPresetsPanel();
bodyEl.append(display, inputRow, controls, presetsEl);
// State wiederherstellen
this._updateDisplay();
this._updateControls();
},
/**
* Presets-Panel erstellen
* @returns {HTMLElement}
*/
_createPresetsPanel() {
const container = document.createElement('div');
container.className = 'timer-presets';
container.id = 'timerPresetsPanel';
const header = document.createElement('div');
header.className = 'timer-presets-header';
const title = document.createElement('span');
title.className = 'timer-presets-title';
title.textContent = 'Presets';
const addBtn = document.createElement('button');
addBtn.className = 'timer-preset-add';
addBtn.type = 'button';
addBtn.textContent = '+';
addBtn.title = 'Preset speichern';
addBtn.addEventListener('click', () => this._showAddPreset(container));
header.append(title, addBtn);
container.appendChild(header);
this._renderPresetItems(container);
return container;
},
/**
* Preset-Items rendern
* @param {HTMLElement} container
*/
_renderPresetItems(container) {
// Alte Items entfernen
const oldItems = container.querySelectorAll('.timer-preset-item, .timer-add-row');
oldItems.forEach(item => item.remove());
this._presets.forEach((preset, idx) => {
const item = document.createElement('div');
item.className = 'timer-preset-item';
const name = document.createElement('span');
name.className = 'timer-preset-name';
name.textContent = preset.name;
const time = document.createElement('span');
time.className = 'timer-preset-time';
time.textContent = this._formatTime(preset.seconds);
const del = document.createElement('button');
del.className = 'timer-preset-del';
del.type = 'button';
del.textContent = '\u2715';
del.addEventListener('click', async (e) => {
e.stopPropagation();
await this._deletePreset(idx);
this._renderPresetItems(container);
});
item.append(name, time, del);
// Klick laedt Preset
item.addEventListener('click', () => {
this._loadPreset(preset);
});
container.appendChild(item);
});
},
/**
* Add-Preset UI anzeigen
* @param {HTMLElement} container
*/
_showAddPreset(container) {
// Nur einmal anzeigen
if (container.querySelector('.timer-add-row')) return;
if (this._presets.length >= this.MAX_PRESETS) {
HellionDialog.alert(
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
{ type: 'warning', title: 'Limit erreicht' }
);
return;
}
// Aktuelle Zeit als Vorlage
const currentSeconds = this._remaining > 0 ? this._seconds : 0;
if (currentSeconds === 0 && this._inputEl) {
const parsed = this._parseTimeInput(this._inputEl.value);
if (parsed === 0) {
HellionDialog.alert(
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
{ type: 'info', title: 'Keine Zeit' }
);
return;
}
}
const row = document.createElement('div');
row.className = 'timer-add-row';
const nameInput = document.createElement('input');
nameInput.className = 'timer-add-input';
nameInput.type = 'text';
nameInput.placeholder = 'Name...';
nameInput.maxLength = 20;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'timer-add-confirm';
confirmBtn.type = 'button';
confirmBtn.textContent = 'OK';
const doAdd = async () => {
const name = nameInput.value.trim();
if (!name) return;
let secs = this._seconds;
if (secs === 0 && this._inputEl) {
secs = this._parseTimeInput(this._inputEl.value);
}
if (secs === 0) return;
await this._addPreset(name, secs);
this._renderPresetItems(container);
};
confirmBtn.addEventListener('click', doAdd);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doAdd();
if (e.key === 'Escape') row.remove();
});
row.append(nameInput, confirmBtn);
container.appendChild(row);
nameInput.focus();
},
// ---- TIMER LOGIC ----
/**
* Input-Feld auslesen und als Sekunden setzen
*/
_applyInput() {
if (!this._inputEl) return;
const secs = this._parseTimeInput(this._inputEl.value);
if (secs > 0) {
this._seconds = secs;
this._remaining = secs;
}
},
/**
* Timer starten
*/
_start() {
if (this._running) return;
if (this._remaining <= 0) return;
// Falls gerade Alarm laeuft, stoppen
if (this._finished) {
this._stopAlarm();
this._finished = false;
}
this._running = true;
this._updateControls();
// Input verstecken
if (this._inputRow) this._inputRow.style.display = 'none';
this._intervalId = setInterval(() => this._tick(), 1000);
},
/**
* Timer pausieren
*/
_pause() {
if (!this._running) return;
this._running = false;
this._stopCountdown();
this._updateControls();
},
/**
* Timer zuruecksetzen
*/
_reset() {
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._remaining = 0;
this._seconds = 0;
// Input wieder anzeigen
if (this._inputRow) this._inputRow.style.display = 'flex';
if (this._inputEl) this._inputEl.value = '';
this._updateDisplay();
this._updateControls();
},
/**
* Jede Sekunde: remaining verringern, Display aktualisieren
*/
_tick() {
this._remaining--;
if (this._remaining <= 0) {
this._remaining = 0;
this._stopCountdown();
this._running = false;
this._finished = true;
this._onFinish();
}
this._updateDisplay();
this._updateControls();
},
/**
* Interval stoppen
*/
_stopCountdown() {
if (this._intervalId) {
clearInterval(this._intervalId);
this._intervalId = null;
}
},
/**
* Timer abgelaufen — Alarm + Tab-Blink
*/
_onFinish() {
if (!this._muted) this._playAlarm();
this._startTitleBlink();
},
/**
* Akustisches Signal (Browser Audio API, kein externer Request)
*/
_playAlarm() {
try {
const ctx = new AudioContext();
[0, 0.3, 0.6].forEach(delay => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800;
gain.gain.value = 0.07;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.2);
});
} catch (e) {
console.warn('Timer: Audio nicht verfuegbar', e);
}
},
/**
* Tab-Titel blinken lassen
*/
_startTitleBlink() {
this._originalTitle = document.title;
this._blinkIntervalId = setInterval(() => {
document.title = document.title === '[!] Timer abgelaufen'
? this._originalTitle
: '[!] Timer abgelaufen';
}, 1000);
},
/**
* Tab-Titel Blink und Alarm stoppen
*/
_stopAlarm() {
if (this._blinkIntervalId) {
clearInterval(this._blinkIntervalId);
this._blinkIntervalId = null;
document.title = this._originalTitle || 'Hellion Dashboard';
}
this._finished = false;
this._updateDisplay();
this._updateControls();
},
/**
* Mute-Button Text/Titel aktualisieren
*/
_updateMuteBtn() {
if (!this._muteBtn) return;
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
this._muteBtn.classList.toggle('muted', this._muted);
},
// ---- DISPLAY ----
/**
* Zeitanzeige aktualisieren
*/
_updateDisplay() {
if (!this._timeEl) return;
this._timeEl.textContent = this._formatTime(this._remaining);
this._timeEl.classList.toggle('finished', this._finished);
},
/**
* Button-States aktualisieren
*/
_updateControls() {
if (this._btnStart) {
this._btnStart.disabled = this._running;
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
}
if (this._btnPause) {
this._btnPause.disabled = !this._running;
}
},
// ---- PRESETS ----
/**
* Preset hinzufuegen
* @param {string} name
* @param {number} seconds
*/
async _addPreset(name, seconds) {
if (this._presets.length >= this.MAX_PRESETS) return;
this._presets.push({ name, seconds });
await this.save();
},
/**
* Preset loeschen
* @param {number} index
*/
async _deletePreset(index) {
this._presets.splice(index, 1);
await this.save();
},
/**
* Preset laden (Zeit setzen)
* @param {Object} preset - { name, seconds }
*/
_loadPreset(preset) {
// Falls laufend, erst stoppen
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._seconds = preset.seconds;
this._remaining = preset.seconds;
if (this._inputRow) this._inputRow.style.display = 'none';
this._updateDisplay();
this._updateControls();
},
// ---- FORMATTING ----
/**
* Sekunden in Zeitformat umwandeln
* @param {number} totalSeconds
* @returns {string} "05:30" oder "1:05:30"
*/
_formatTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
if (h > 0) {
return h + ':' + mm + ':' + ss;
}
return mm + ':' + ss;
},
/**
* Zeit-String in Sekunden parsen
* Akzeptiert: "5:30", "05:30", "1:05:30", "90" (Sekunden)
* @param {string} str
* @returns {number}
*/
_parseTimeInput(str) {
const trimmed = (str || '').trim();
if (!trimmed) return 0;
const parts = trimmed.split(':');
if (parts.length === 1) {
// Nur Zahl = Sekunden
const secs = parseInt(parts[0], 10);
return isNaN(secs) ? 0 : Math.max(0, secs);
}
if (parts.length === 2) {
// mm:ss
const m = parseInt(parts[0], 10);
const s = parseInt(parts[1], 10);
if (isNaN(m) || isNaN(s)) return 0;
return Math.max(0, m * 60 + s);
}
if (parts.length === 3) {
// hh:mm:ss
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
const s = parseInt(parts[2], 10);
if (isNaN(h) || isNaN(m) || isNaN(s)) return 0;
return Math.max(0, h * 3600 + m * 60 + s);
}
return 0;
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nicht reagieren wenn User in Input tippt
if (e.target.tagName === 'INPUT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (this._running) {
this._pause();
} else if (this._remaining > 0) {
this._start();
}
} else if (e.key === 'Escape' || e.key === 'r' || e.key === 'R') {
e.preventDefault();
this._reset();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
widgetEl.tabIndex = 0;
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Timer initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Wenn Timer beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer && data.timer.open) {
await this.open();
}
// Close-Event abfangen
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Open-Event abfangen
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
if (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);
await self.save();
}
};
}
};
+1 -1
View File
@@ -6,7 +6,7 @@
const WidgetManager = {
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
_widgets: new Map(),
_topZ: 51,
_topZ: 100,
STORAGE_KEY: 'widgetStates',
/**