Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40d4d9f37a | |||
| 198171b6c2 | |||
| 51947b229c | |||
| 2f0b76eb4e | |||
| 32a6fe88dc | |||
| 95e45948be | |||
| a76f63c407 | |||
| 18a04b884c | |||
| 7a16462358 |
@@ -0,0 +1,4 @@
|
|||||||
|
# Hellion NewTab — Code Owners
|
||||||
|
# Alle Änderungen müssen von @JonKazama-Hellion approved werden
|
||||||
|
|
||||||
|
* @JonKazama-Hellion
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# These are supported funding model platforms
|
||||||
|
|
||||||
|
|
||||||
|
ko_fi: hellionmedia
|
||||||
@@ -16,6 +16,10 @@ dist/
|
|||||||
node_modules/
|
node_modules/
|
||||||
/xpi/
|
/xpi/
|
||||||
v2-planning.md
|
v2-planning.md
|
||||||
|
themes-v2.md
|
||||||
|
|
||||||
|
# Firefox Update-Manifest (wird auf hellion-media.de gehostet)
|
||||||
|
updates.json
|
||||||
|
|
||||||
# Persönliche Backup-Dateien (nicht ins Repo)
|
# Persönliche Backup-Dateien (nicht ins Repo)
|
||||||
favorites_*.html
|
favorites_*.html
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ⬡ Hellion Dashboard v1.5.2
|
# ⬡ Hellion Dashboard v1.9.0
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|||||||
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 366 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 247 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 76 KiB |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "Hellion NewTab",
|
||||||
"version": "1.5.2",
|
"version": "1.11.1",
|
||||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "hellion-newtab@hellion-media.de",
|
"id": "hellion-newtab@hellion-media.de",
|
||||||
|
"update_url": "https://hellion-media.de/extensions/firefox/updates.json",
|
||||||
"strict_min_version": "142.0",
|
"strict_min_version": "142.0",
|
||||||
"data_collection_permissions": {
|
"data_collection_permissions": {
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion NewTab",
|
"name": "Hellion NewTab",
|
||||||
"version": "1.5.2",
|
"version": "1.11.1",
|
||||||
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
"description": "Personal bookmark dashboard — local, private, no account needed. By Hellion Online Media.",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Hellion Dashboard (GX Native)",
|
"name": "Hellion Dashboard (GX Native)",
|
||||||
"version": "1.5.2",
|
"version": "1.11.1",
|
||||||
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
|
"description": "Ersetzt die Opera GX Startseite durch dein persönliches, leistungsoptimiertes Hellion Dashboard. Schnell, sauber und werbefrei.",
|
||||||
"author": "Hellion Online Media - Florian Wathling",
|
"author": "Hellion Online Media - Florian Wathling",
|
||||||
"homepage_url": "https://hellion-media.de",
|
"homepage_url": "https://hellion-media.de",
|
||||||
|
|||||||
@@ -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>
|
<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
|
Note
|
||||||
</button>
|
</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>
|
<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>
|
||||||
<button class="btn-icon" id="btnSettings" title="Einstellungen">
|
<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>
|
<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>
|
||||||
@@ -59,18 +59,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- STICKY NOTE -->
|
<!-- WIDGET TOOLBAR -->
|
||||||
<div class="sticky-note" id="stickyNote">
|
<div class="widget-toolbar" id="widgetToolbar">
|
||||||
<div class="sticky-note-header" id="stickyNoteHeader">
|
<button class="widget-toolbar-btn" data-action="new-note" title="Note erstellen">
|
||||||
<span class="sticky-note-title">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
<svg width="11" height="11" 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>
|
</button>
|
||||||
Note
|
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
||||||
</span>
|
<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 class="sticky-note-close" id="stickyNoteClose">✕</button>
|
</button>
|
||||||
</div>
|
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
|
||||||
<textarea class="sticky-note-body" id="stickyNoteBody" placeholder="Quick note…" spellcheck="false"></textarea>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- NOTEBOOK SIDEBAR -->
|
||||||
|
<div class="notebook-overlay" id="notebookOverlay"></div>
|
||||||
|
<aside class="notebook-panel" id="notebookPanel">
|
||||||
|
<div class="notebook-header">
|
||||||
|
<span class="notebook-header-title">Notebook <span class="notebook-count" id="notebookCount">0 / 5</span></span>
|
||||||
|
<button class="btn-close" id="btnCloseNotebook">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="notebook-slots" id="notebookSlots">
|
||||||
|
<!-- dynamisch via JS -->
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<!-- BOARDS CONTAINER -->
|
<!-- BOARDS CONTAINER -->
|
||||||
<main class="boards-wrapper" id="boardsWrapper">
|
<main class="boards-wrapper" id="boardsWrapper">
|
||||||
<!-- dynamisch via JS -->
|
<!-- dynamisch via JS -->
|
||||||
@@ -83,116 +105,70 @@
|
|||||||
<div class="panel-overlay" id="settingsOverlay"></div>
|
<div class="panel-overlay" id="settingsOverlay"></div>
|
||||||
<aside class="settings-panel" id="settingsPanel">
|
<aside class="settings-panel" id="settingsPanel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span>Settings</span>
|
<span>Einstellungen</span>
|
||||||
<button class="btn-close" id="btnCloseSettings">✕</button>
|
<button class="btn-close" id="btnCloseSettings">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel-body">
|
<div class="panel-body">
|
||||||
|
|
||||||
<!-- APPEARANCE -->
|
<!-- WIDGETS -->
|
||||||
<section class="settings-section" data-section="appearance">
|
<section class="settings-section" data-section="widgets">
|
||||||
<button class="settings-section-title" type="button">
|
<button class="settings-section-title" type="button">
|
||||||
<span class="section-chevron">▸</span>
|
<span class="section-chevron">▸</span>
|
||||||
APPEARANCE
|
WIDGETS
|
||||||
</button>
|
</button>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Compact mode</span>
|
<span class="setting-label">Toolbar-Position</span>
|
||||||
<span class="setting-desc">Reduce spacing to show more bookmarks</span>
|
<span class="setting-desc">Widget-Toolbar links oder rechts anzeigen</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingCompact" /><span class="slider"></span></label>
|
<select class="select-input" id="settingToolbarPos">
|
||||||
</div>
|
<option value="right" selected>Rechts</option>
|
||||||
<div class="setting-row">
|
<option value="left">Links</option>
|
||||||
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Suchleiste anzeigen</span>
|
<span class="setting-label">Bild-Referenz Widgets</span>
|
||||||
<span class="setting-desc">Suchleiste unter dem Header ein/aus</span>
|
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="toggle"><input type="checkbox" id="settingShowSearch" checked /><span class="slider"></span></label>
|
<label class="toggle">
|
||||||
|
<input type="checkbox" id="settingImageRef">
|
||||||
|
<span class="slider"></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- DATA -->
|
<!-- DATEN & HILFE -->
|
||||||
<section class="settings-section" data-section="data">
|
<section class="settings-section" data-section="data">
|
||||||
<button class="settings-section-title" type="button">
|
<button class="settings-section-title" type="button">
|
||||||
<span class="section-chevron">▸</span>
|
<span class="section-chevron">▸</span>
|
||||||
DATA
|
DATEN & HILFE
|
||||||
</button>
|
</button>
|
||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Export Boards</span>
|
<span class="setting-label">Backup exportieren</span>
|
||||||
<span class="setting-desc">Alle Boards als JSON sichern</span>
|
<span class="setting-desc">Alle Boards, Notes und Einstellungen als JSON sichern</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnExportJSON">Export</button>
|
<button class="btn-small" id="btnExportJSON">Export</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<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>
|
<span class="setting-desc">JSON-Backup wiederherstellen</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnImportJSON">Import</button>
|
<button class="btn-small" id="btnImportJSON">Import</button>
|
||||||
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
|
<input type="file" id="jsonImportInput" accept=".json" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="setting-row" id="browserImportRow">
|
||||||
</section>
|
<div class="setting-info">
|
||||||
|
<span class="setting-label">Browser-Lesezeichen</span>
|
||||||
<!-- HELP -->
|
<span class="setting-desc">Lesezeichen direkt aus dem Browser importieren</span>
|
||||||
<section class="settings-section" data-section="help">
|
</div>
|
||||||
<button class="settings-section-title" type="button">
|
<button class="btn-small" id="btnBrowserImport">Import</button>
|
||||||
<span class="section-chevron">▸</span>
|
</div>
|
||||||
HELP
|
|
||||||
</button>
|
|
||||||
<div class="section-content">
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Onboarding wiederholen</span>
|
<span class="setting-label">Onboarding wiederholen</span>
|
||||||
@@ -203,81 +179,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ABOUT / IMPRESSUM -->
|
|
||||||
<section class="settings-section" data-section="about">
|
|
||||||
<button class="settings-section-title" type="button">
|
|
||||||
<span class="section-chevron">▸</span>
|
|
||||||
ABOUT
|
|
||||||
</button>
|
|
||||||
<div class="section-content">
|
|
||||||
<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-links">
|
|
||||||
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
|
||||||
Impressum
|
|
||||||
</a>
|
|
||||||
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
|
||||||
hellion-media.de
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="about-divider"></div>
|
|
||||||
|
|
||||||
<div class="about-info-row">
|
|
||||||
<span class="about-info-label">Entwickler</span>
|
|
||||||
<span class="about-info-value">Florian Wathling</span>
|
|
||||||
</div>
|
|
||||||
<div class="about-info-row">
|
|
||||||
<span class="about-info-label">Unternehmen</span>
|
|
||||||
<span class="about-info-value">Hellion Online Media</span>
|
|
||||||
</div>
|
|
||||||
<div class="about-info-row">
|
|
||||||
<span class="about-info-label">Lizenz</span>
|
|
||||||
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
|
||||||
</div>
|
|
||||||
<div class="about-info-row">
|
|
||||||
<span class="about-info-label">Datenspeicherung</span>
|
|
||||||
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="about-divider"></div>
|
|
||||||
|
|
||||||
<div class="about-bugreport">
|
|
||||||
<span class="about-info-label about-info-label-block">Bug Report / Feedback</span>
|
|
||||||
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
|
||||||
kontakt@hellion-media.de
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="about-bugreport">
|
|
||||||
<span class="about-info-label about-info-label-block">Support</span>
|
|
||||||
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
|
||||||
Ko-fi — hellionmedia
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="about-browsers">
|
|
||||||
<span class="about-info-label about-info-label-block">Kompatible Browser</span>
|
|
||||||
<div class="about-browser-tags">
|
|
||||||
<span class="browser-tag">Chrome</span>
|
|
||||||
<span class="browser-tag">Edge</span>
|
|
||||||
<span class="browser-tag">Firefox</span>
|
|
||||||
<span class="browser-tag">Opera</span>
|
|
||||||
<span class="browser-tag">Opera GX</span>
|
|
||||||
<span class="browser-tag">Brave</span>
|
|
||||||
<span class="browser-tag">Vivaldi</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- DANGER ZONE -->
|
<!-- DANGER ZONE -->
|
||||||
<section class="settings-section" data-section="danger">
|
<section class="settings-section" data-section="danger">
|
||||||
<button class="settings-section-title danger" type="button">
|
<button class="settings-section-title danger" type="button">
|
||||||
@@ -287,8 +188,8 @@
|
|||||||
<div class="section-content">
|
<div class="section-content">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Reset all data</span>
|
<span class="setting-label">Alles zurücksetzen</span>
|
||||||
<span class="setting-desc">Deletes all boards and bookmarks</span>
|
<span class="setting-desc">Löscht alle Boards, Notes und Einstellungen</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-danger" id="btnResetAll">Reset</button>
|
<button class="btn-danger" id="btnResetAll">Reset</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -296,28 +197,97 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</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.11.1 · by Hellion Online Media</div>
|
||||||
|
|
||||||
|
<div class="about-links">
|
||||||
|
<a href="https://hellion-media.de/impressum" target="_blank" class="about-link">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||||
|
Impressum
|
||||||
|
</a>
|
||||||
|
<a href="https://hellion-media.de" target="_blank" class="about-link">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/></svg>
|
||||||
|
hellion-media.de
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-divider"></div>
|
||||||
|
|
||||||
|
<div class="about-info-row">
|
||||||
|
<span class="about-info-label">Entwickler</span>
|
||||||
|
<span class="about-info-value">Florian Wathling</span>
|
||||||
|
</div>
|
||||||
|
<div class="about-info-row">
|
||||||
|
<span class="about-info-label">Unternehmen</span>
|
||||||
|
<span class="about-info-value">Hellion Online Media</span>
|
||||||
|
</div>
|
||||||
|
<div class="about-info-row">
|
||||||
|
<span class="about-info-label">Lizenz</span>
|
||||||
|
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" class="about-info-value about-link-subtle">CC BY-NC-SA 4.0</a>
|
||||||
|
</div>
|
||||||
|
<div class="about-info-row">
|
||||||
|
<span class="about-info-label">Datenspeicherung</span>
|
||||||
|
<span class="about-info-value">100% lokal · Kein Server · Kein Account</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-divider"></div>
|
||||||
|
|
||||||
|
<div class="about-bugreport">
|
||||||
|
<span class="about-info-label about-info-label-block">Bug Report / Feedback</span>
|
||||||
|
<a href="mailto:kontakt@hellion-media.de?subject=Hellion NewTab – Bug Report" class="about-link-mail">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
||||||
|
kontakt@hellion-media.de
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-bugreport">
|
||||||
|
<span class="about-info-label about-info-label-block">Support</span>
|
||||||
|
<a href="https://ko-fi.com/hellionmedia" target="_blank" class="about-link">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8h1a4 4 0 010 8h-1"/><path d="M2 8h16v9a4 4 0 01-4 4H6a4 4 0 01-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>
|
||||||
|
Ko-fi — hellionmedia
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="about-browsers">
|
||||||
|
<span class="about-info-label about-info-label-block">Kompatible Browser</span>
|
||||||
|
<div class="about-browser-tags">
|
||||||
|
<span class="browser-tag">Chrome</span>
|
||||||
|
<span class="browser-tag">Edge</span>
|
||||||
|
<span class="browser-tag">Firefox</span>
|
||||||
|
<span class="browser-tag">Opera</span>
|
||||||
|
<span class="browser-tag">Opera GX</span>
|
||||||
|
<span class="browser-tag">Brave</span>
|
||||||
|
<span class="browser-tag">Vivaldi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- THEME PICKER MODAL -->
|
<!-- THEME PICKER MODAL -->
|
||||||
<div class="modal-overlay" id="themeOverlay">
|
<div class="modal-overlay" id="themeOverlay">
|
||||||
<div class="theme-modal" id="themeModal">
|
<div class="theme-modal" id="themeModal">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<span>Theme wählen</span>
|
<span>Darstellung</span>
|
||||||
<button class="btn-close" id="btnCloseTheme">✕</button>
|
<button class="btn-close" id="btnCloseTheme">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-grid">
|
<div class="theme-grid">
|
||||||
<div class="theme-card active" data-value="nebula">
|
<div class="theme-card active" data-value="nebula">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-nebula.jpg" alt="Nebula" />
|
<img class="theme-card-img" src="assets/themes/bg-nebula.webp" alt="Nebula" />
|
||||||
<span class="theme-card-label">Nebula</span>
|
<span class="theme-card-label">Nebula</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="crescent">
|
<div class="theme-card" data-value="crescent">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-crescent.jpg" alt="Crescent" />
|
<img class="theme-card-img" src="assets/themes/bg-crescent.webp" alt="Crescent" />
|
||||||
<span class="theme-card-label">Crescent</span>
|
<span class="theme-card-label">Crescent</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="event-horizon">
|
<div class="theme-card" data-value="event-horizon">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-event-horizon.jpg" alt="Event Horizon" />
|
<img class="theme-card-img" src="assets/themes/bg-event-horizon.webp" alt="Event Horizon" />
|
||||||
<span class="theme-card-label">Event Horizon</span>
|
<span class="theme-card-label">Event Horizon</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,48 +297,119 @@
|
|||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="julia-jin">
|
<div class="theme-card" data-value="julia-jin">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-julia-jin.png" alt="Julia & Jin" />
|
<img class="theme-card-img" src="assets/themes/bg-julia-jin.webp" alt="Julia & Jin" />
|
||||||
<span class="theme-card-label">Julia & Jin</span>
|
<span class="theme-card-label">Julia & Jin</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="sc-sunset">
|
<div class="theme-card" data-value="sc-sunset">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.jpg" alt="SC Sunset" />
|
<img class="theme-card-img" src="assets/themes/bg-sc-sunset.webp" alt="SC Sunset" />
|
||||||
<span class="theme-card-label">SC Sunset</span>
|
<span class="theme-card-label">SC Sunset</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="hellion-hud">
|
<div class="theme-card" data-value="hellion-hud">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.png" alt="Hellion HUD" />
|
<img class="theme-card-img" src="assets/themes/bg-hellion-hud.webp" alt="Hellion HUD" />
|
||||||
<span class="theme-card-label">HUD</span>
|
<span class="theme-card-label">HUD</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-card" data-value="hellion-energy">
|
<div class="theme-card" data-value="hellion-energy">
|
||||||
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.jpg" alt="Hellion Energy" />
|
<img class="theme-card-img" src="assets/themes/bg-hellion-energy.webp" alt="Hellion Energy" />
|
||||||
<span class="theme-card-label">Energy</span>
|
<span class="theme-card-label">Energy</span>
|
||||||
<span class="theme-card-check">✓</span>
|
<span class="theme-card-check">✓</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="theme-card" data-value="satisfactory">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-satisfactory.webp" alt="Satisfactory" />
|
||||||
|
<span class="theme-card-label">Satisfactory</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="avorion">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-avorion.webp" alt="Avorion" />
|
||||||
|
<span class="theme-card-label">Avorion</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
|
<div class="theme-card" data-value="hellion-stealth">
|
||||||
|
<img class="theme-card-img" src="assets/themes/bg-scPolaris.webp" alt="Hellion Stealth" />
|
||||||
|
<span class="theme-card-label">Stealth</span>
|
||||||
|
<span class="theme-card-check">✓</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="theme-modal-section">
|
<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-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Background image URL</span>
|
<span class="setting-label">Bild-URL</span>
|
||||||
<span class="setting-desc">Custom wallpaper URL</span>
|
<span class="setting-desc">Eigenes Hintergrundbild per URL</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnChangeBg">Change</button>
|
<button class="btn-small" id="btnChangeBg">Ändern</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row hidden" id="bgInputRow">
|
<div class="setting-row hidden" id="bgInputRow">
|
||||||
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... or leave empty for default" />
|
<input type="text" class="text-input full-width" id="bgUrlInput" placeholder="https://... oder leer für Standard" />
|
||||||
<button class="btn-small" id="btnApplyBg">Apply</button>
|
<button class="btn-small" id="btnApplyBg">Übernehmen</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<span class="setting-label">Background file upload</span>
|
<span class="setting-label">Datei hochladen</span>
|
||||||
<span class="setting-desc">Use a local image as background</span>
|
<span class="setting-desc">Lokales Bild als Hintergrund verwenden</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-small" id="btnBgFile">Upload</button>
|
<button class="btn-small" id="btnBgFile">Upload</button>
|
||||||
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
|
<input type="file" id="bgFileInput" accept="image/*" class="hidden" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -440,7 +481,12 @@
|
|||||||
<script src="src/js/boards.js"></script>
|
<script src="src/js/boards.js"></script>
|
||||||
<script src="src/js/settings.js"></script>
|
<script src="src/js/settings.js"></script>
|
||||||
<script src="src/js/search.js"></script>
|
<script src="src/js/search.js"></script>
|
||||||
<script src="src/js/sticky.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/bookmark-import.js"></script>
|
||||||
<script src="src/js/data.js"></script>
|
<script src="src/js/data.js"></script>
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<script src="src/js/onboarding.js"></script>
|
<script src="src/js/onboarding.js"></script>
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ async function init() {
|
|||||||
bindGlobalEvents();
|
bindGlobalEvents();
|
||||||
bindSettingsEvents();
|
bindSettingsEvents();
|
||||||
initSearch();
|
initSearch();
|
||||||
initStickyNote();
|
await migrateSticky();
|
||||||
|
await Notes.init();
|
||||||
|
await Calculator.init();
|
||||||
|
await Timer.init();
|
||||||
|
await ImageRef.init();
|
||||||
|
BrowserBookmarkImport.init();
|
||||||
initDataButtons();
|
initDataButtons();
|
||||||
Store.checkQuota();
|
Store.checkQuota();
|
||||||
|
|
||||||
@@ -30,6 +35,46 @@ async function init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- STICKY NOTE MIGRATION ----
|
||||||
|
async function migrateSticky() {
|
||||||
|
const stickyText = await Store.get('stickyNote');
|
||||||
|
const stickyPos = await Store.get('stickyPos');
|
||||||
|
const existingWidgets = await Store.get('widgetStates');
|
||||||
|
|
||||||
|
// Nur migrieren wenn alte Daten vorhanden UND noch keine Widgets existieren
|
||||||
|
if (!stickyText && !stickyPos) return;
|
||||||
|
if (existingWidgets && Array.isArray(existingWidgets.notes) && existingWidgets.notes.length > 0) return;
|
||||||
|
|
||||||
|
const noteData = {
|
||||||
|
id: 'note_' + uid(),
|
||||||
|
title: (stickyText || '').split('\n')[0].trim().slice(0, 20) || 'Note',
|
||||||
|
content: stickyText || '',
|
||||||
|
template: 'text',
|
||||||
|
x: stickyPos ? stickyPos.x : 120,
|
||||||
|
y: stickyPos ? stickyPos.y : 80,
|
||||||
|
width: 280,
|
||||||
|
height: 220,
|
||||||
|
open: true,
|
||||||
|
checkedItems: [],
|
||||||
|
checklistItems: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await Store.set('widgetStates', { notes: [noteData] });
|
||||||
|
|
||||||
|
// Alte Keys aufraeumen
|
||||||
|
try {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.storage) {
|
||||||
|
chrome.storage.local.remove(['stickyNote', 'stickyPos', 'stickyVisible']);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('stickyNote');
|
||||||
|
localStorage.removeItem('stickyPos');
|
||||||
|
localStorage.removeItem('stickyVisible');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Sticky-Migration: Alte Keys konnten nicht entfernt werden', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- BACKUP REMINDER ----
|
// ---- BACKUP REMINDER ----
|
||||||
const BACKUP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
const BACKUP_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // 7 Tage
|
||||||
|
|
||||||
@@ -55,7 +100,11 @@ async function checkBackupReminder() {
|
|||||||
|
|
||||||
if (doBackup) {
|
if (doBackup) {
|
||||||
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
|
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
|
||||||
const data = { version: '1.5.2', exported: new Date().toISOString(), boards, settings };
|
const widgetData = await Store.get('widgetStates');
|
||||||
|
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
||||||
|
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
||||||
|
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
||||||
|
const data = { version: '1.11.1', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — bookmark-import.js
|
||||||
|
Direkt-Import von Browser-Lesezeichen
|
||||||
|
via chrome.bookmarks.getTree() / browser.bookmarks.getTree()
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const BrowserBookmarkImport = {
|
||||||
|
|
||||||
|
/** Initialisiert den Import-Button */
|
||||||
|
init() {
|
||||||
|
const btn = document.getElementById('btnBrowserImport');
|
||||||
|
const row = document.getElementById('browserImportRow');
|
||||||
|
if (!btn || !row) return;
|
||||||
|
|
||||||
|
// API-Verfuegbarkeit pruefen (nicht vorhanden im normalen Browser-Tab)
|
||||||
|
const api = this._getApi();
|
||||||
|
if (!api) {
|
||||||
|
row.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', () => this._openFolderModal());
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt die Bookmarks-API zurueck (Chrome oder Firefox)
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
_getApi() {
|
||||||
|
if (typeof chrome !== 'undefined' && chrome.bookmarks) return chrome.bookmarks;
|
||||||
|
if (typeof browser !== 'undefined' && browser.bookmarks) return browser.bookmarks;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Oeffnet das Ordner-Auswahl Modal */
|
||||||
|
async _openFolderModal() {
|
||||||
|
const api = this._getApi();
|
||||||
|
if (!api) return;
|
||||||
|
|
||||||
|
let tree;
|
||||||
|
try {
|
||||||
|
tree = await api.getTree();
|
||||||
|
} catch (err) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Zugriff auf Browser-Lesezeichen nicht möglich. Stelle sicher, dass die Extension die nötigen Berechtigungen hat.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders = this._extractFolders(tree[0]);
|
||||||
|
if (folders.length === 0) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Keine Lesezeichen-Ordner gefunden.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._renderModal(folders);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrahiert alle Ordner rekursiv aus dem Bookmark-Baum
|
||||||
|
* @param {object} node - Bookmark-Tree Node
|
||||||
|
* @param {number} depth - Einrueckungstiefe
|
||||||
|
* @returns {Array}
|
||||||
|
*/
|
||||||
|
_extractFolders(node, depth) {
|
||||||
|
if (depth === undefined) depth = 0;
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (!node.children) return result;
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.children) {
|
||||||
|
const bookmarkCount = child.children.filter(function(c) { return c.url; }).length;
|
||||||
|
const subfolderCount = child.children.filter(function(c) { return c.children; }).length;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: child.id,
|
||||||
|
title: child.title || 'Unbenannt',
|
||||||
|
depth: depth,
|
||||||
|
bookmarkCount: bookmarkCount,
|
||||||
|
subfolderCount: subfolderCount,
|
||||||
|
node: child
|
||||||
|
});
|
||||||
|
|
||||||
|
const subFolders = this._extractFolders(child, depth + 1);
|
||||||
|
for (const sf of subFolders) {
|
||||||
|
result.push(sf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendert das Ordner-Auswahl Modal
|
||||||
|
* @param {Array} folders - Liste der Ordner
|
||||||
|
*/
|
||||||
|
_renderModal(folders) {
|
||||||
|
// Overlay
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'bm-import-overlay';
|
||||||
|
overlay.id = 'bmImportOverlay';
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'bm-import-modal';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'bm-import-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.textContent = 'Browser-Lesezeichen importieren';
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.className = 'bm-import-close';
|
||||||
|
closeBtn.textContent = '\u00D7';
|
||||||
|
closeBtn.addEventListener('click', () => this._closeModal());
|
||||||
|
header.appendChild(closeBtn);
|
||||||
|
|
||||||
|
modal.appendChild(header);
|
||||||
|
|
||||||
|
// Info
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'bm-import-info';
|
||||||
|
info.textContent = 'Wähle die Ordner aus, die als Boards importiert werden sollen. Jeder Ordner wird ein eigenes Board.';
|
||||||
|
modal.appendChild(info);
|
||||||
|
|
||||||
|
// Ordner-Liste
|
||||||
|
const list = document.createElement('div');
|
||||||
|
list.className = 'bm-import-list';
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
const row = document.createElement('label');
|
||||||
|
row.className = 'bm-import-folder';
|
||||||
|
row.style.paddingLeft = (12 + folder.depth * 20) + 'px';
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.className = 'bm-import-checkbox';
|
||||||
|
checkbox.dataset.folderId = folder.id;
|
||||||
|
row.appendChild(checkbox);
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'bm-import-folder-name';
|
||||||
|
label.textContent = folder.title;
|
||||||
|
row.appendChild(label);
|
||||||
|
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'bm-import-folder-meta';
|
||||||
|
const parts = [];
|
||||||
|
if (folder.bookmarkCount > 0) {
|
||||||
|
parts.push(folder.bookmarkCount + ' Link' + (folder.bookmarkCount !== 1 ? 's' : ''));
|
||||||
|
}
|
||||||
|
if (folder.subfolderCount > 0) {
|
||||||
|
parts.push(folder.subfolderCount + ' Ordner');
|
||||||
|
}
|
||||||
|
if (parts.length === 0) {
|
||||||
|
parts.push('leer');
|
||||||
|
}
|
||||||
|
meta.textContent = parts.join(', ');
|
||||||
|
row.appendChild(meta);
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.appendChild(list);
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
const footer = document.createElement('div');
|
||||||
|
footer.className = 'bm-import-footer';
|
||||||
|
|
||||||
|
const selectAll = document.createElement('button');
|
||||||
|
selectAll.className = 'btn-secondary';
|
||||||
|
selectAll.textContent = 'Alle auswählen';
|
||||||
|
selectAll.addEventListener('click', () => {
|
||||||
|
const boxes = list.querySelectorAll('.bm-import-checkbox');
|
||||||
|
const allChecked = Array.from(boxes).every(function(cb) { return cb.checked; });
|
||||||
|
boxes.forEach(function(cb) { cb.checked = !allChecked; });
|
||||||
|
selectAll.textContent = allChecked ? 'Alle auswählen' : 'Alle abwählen';
|
||||||
|
});
|
||||||
|
footer.appendChild(selectAll);
|
||||||
|
|
||||||
|
const importBtn = document.createElement('button');
|
||||||
|
importBtn.className = 'btn-primary';
|
||||||
|
importBtn.textContent = 'Importieren';
|
||||||
|
importBtn.addEventListener('click', () => this._importSelected(folders));
|
||||||
|
footer.appendChild(importBtn);
|
||||||
|
|
||||||
|
modal.appendChild(footer);
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Animation
|
||||||
|
requestAnimationFrame(() => overlay.classList.add('active'));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Schliesst das Modal */
|
||||||
|
_closeModal() {
|
||||||
|
const overlay = document.getElementById('bmImportOverlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
overlay.classList.remove('active');
|
||||||
|
setTimeout(() => overlay.remove(), 250);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Importiert die ausgewaehlten Ordner als Boards
|
||||||
|
* @param {Array} folders - Alle Ordner
|
||||||
|
*/
|
||||||
|
async _importSelected(folders) {
|
||||||
|
const checkboxes = document.querySelectorAll('.bm-import-checkbox:checked');
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Bitte wähle mindestens einen Ordner aus.',
|
||||||
|
{ type: 'warning', title: 'Lesezeichen-Import' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bestehende URLs sammeln fuer Duplikat-Erkennung
|
||||||
|
const existingUrls = new Set();
|
||||||
|
for (const board of boards) {
|
||||||
|
for (const bm of board.bookmarks) {
|
||||||
|
existingUrls.add(bm.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIds = new Set();
|
||||||
|
checkboxes.forEach(function(cb) { selectedIds.add(cb.dataset.folderId); });
|
||||||
|
|
||||||
|
let totalImported = 0;
|
||||||
|
let totalSkipped = 0;
|
||||||
|
let boardsCreated = 0;
|
||||||
|
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!selectedIds.has(folder.id)) continue;
|
||||||
|
|
||||||
|
const bookmarks = [];
|
||||||
|
for (const child of folder.node.children) {
|
||||||
|
if (!child.url) continue;
|
||||||
|
|
||||||
|
// Nur http/https URLs
|
||||||
|
try {
|
||||||
|
const parsed = new URL(child.url);
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue;
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplikat-Check
|
||||||
|
if (existingUrls.has(child.url)) {
|
||||||
|
totalSkipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks.push({
|
||||||
|
id: uid(),
|
||||||
|
title: child.title || child.url,
|
||||||
|
url: child.url,
|
||||||
|
desc: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
existingUrls.add(child.url);
|
||||||
|
totalImported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bookmarks.length === 0) continue;
|
||||||
|
|
||||||
|
boards.push({
|
||||||
|
id: uid(),
|
||||||
|
title: folder.title,
|
||||||
|
bookmarks: bookmarks,
|
||||||
|
blurred: false
|
||||||
|
});
|
||||||
|
boardsCreated++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (boardsCreated > 0) {
|
||||||
|
await saveBoards();
|
||||||
|
renderBoards();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._closeModal();
|
||||||
|
|
||||||
|
// Ergebnis-Dialog
|
||||||
|
const lines = [];
|
||||||
|
lines.push(boardsCreated + ' Board' + (boardsCreated !== 1 ? 's' : '') + ' erstellt');
|
||||||
|
lines.push(totalImported + ' Lesezeichen importiert');
|
||||||
|
if (totalSkipped > 0) {
|
||||||
|
lines.push(totalSkipped + ' Duplikat' + (totalSkipped !== 1 ? 'e' : '') + ' übersprungen');
|
||||||
|
}
|
||||||
|
|
||||||
|
await HellionDialog.alert(
|
||||||
|
lines.join('\n'),
|
||||||
|
{ type: 'success', title: 'Import abgeschlossen' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -9,9 +9,18 @@ function initDataButtons() {
|
|||||||
const jsonInput = document.getElementById('jsonImportInput');
|
const jsonInput = document.getElementById('jsonImportInput');
|
||||||
if (!btnExport || !btnImport) return;
|
if (!btnExport || !btnImport) return;
|
||||||
|
|
||||||
// Export
|
// Export (inkl. Notes)
|
||||||
btnExport.addEventListener('click', () => {
|
btnExport.addEventListener('click', async () => {
|
||||||
const data = { version: '1.5.2', exported: new Date().toISOString(), boards, settings };
|
const widgetData = await Store.get('widgetStates');
|
||||||
|
const data = {
|
||||||
|
version: '1.11.1',
|
||||||
|
exported: new Date().toISOString(),
|
||||||
|
boards,
|
||||||
|
settings,
|
||||||
|
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 blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -50,8 +59,64 @@ function initDataButtons() {
|
|||||||
boards = [...boards, ...validBoards];
|
boards = [...boards, ...validBoards];
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
renderBoards();
|
renderBoards();
|
||||||
|
|
||||||
|
// Notes importieren (falls vorhanden)
|
||||||
|
let notesImported = 0;
|
||||||
|
const existingWidgets = await Store.get('widgetStates') || {};
|
||||||
|
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
||||||
|
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 : [];
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
// Limit beachten
|
||||||
|
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
|
||||||
|
const toImport = importNotes.slice(0, spaceLeft);
|
||||||
|
if (toImport.length > 0) {
|
||||||
|
const merged = [...existingNotes, ...toImport];
|
||||||
|
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(
|
await HellionDialog.alert(
|
||||||
`${validBoards.length} Board(s) erfolgreich importiert.`,
|
`${validBoards.length} Board(s)${noteMsg}${calcMsg}${timerMsg} erfolgreich importiert.`,
|
||||||
{ type: 'success', title: 'Import erfolgreich' }
|
{ type: 'success', title: 'Import erfolgreich' }
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — notes.js
|
||||||
|
Notes: Freitext, Checklisten, Notebook-Sidebar
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const Notes = {
|
||||||
|
MAX_NOTES: 5,
|
||||||
|
MAX_CHARS: 2500,
|
||||||
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
/** @type {Array<Object>} */
|
||||||
|
_notes: [],
|
||||||
|
_saveTimer: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes aus Storage laden
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && Array.isArray(data.notes)) {
|
||||||
|
this._notes = data.notes;
|
||||||
|
}
|
||||||
|
return this._notes;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Notes in Storage speichern
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
// Widget-States mit Note-Daten mergen
|
||||||
|
const widgetStates = WidgetManager.save ? await WidgetManager.save() : [];
|
||||||
|
|
||||||
|
// Note-Daten mit aktuellen Widget-Positionen mergen
|
||||||
|
const merged = this._notes.map(note => {
|
||||||
|
const ws = widgetStates.find(w => w.id === note.id);
|
||||||
|
if (ws) {
|
||||||
|
note.x = ws.x;
|
||||||
|
note.y = ws.y;
|
||||||
|
note.width = ws.width;
|
||||||
|
note.height = ws.height;
|
||||||
|
note.open = ws.open;
|
||||||
|
note.title = ws.title;
|
||||||
|
}
|
||||||
|
return note;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced Save (fuer Auto-Save bei Input)
|
||||||
|
*/
|
||||||
|
_debouncedSave() {
|
||||||
|
clearTimeout(this._saveTimer);
|
||||||
|
this._saveTimer = setTimeout(() => this.save(), 500);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neue Note erstellen
|
||||||
|
* @param {'text'|'checklist'} template
|
||||||
|
* @returns {Promise<string|null>} widget-id oder null bei vollem Limit
|
||||||
|
*/
|
||||||
|
async create(template) {
|
||||||
|
if (this._notes.length >= this.MAX_NOTES) {
|
||||||
|
await HellionDialog.alert(
|
||||||
|
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_NOTES + ' Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
|
||||||
|
{ type: 'warning', title: 'Limit erreicht' }
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteData = {
|
||||||
|
id: 'note_' + uid(),
|
||||||
|
title: template === 'checklist' ? 'Checkliste' : 'Note',
|
||||||
|
content: '',
|
||||||
|
template: template,
|
||||||
|
x: 120 + (this._notes.length * 30),
|
||||||
|
y: 80 + (this._notes.length * 30),
|
||||||
|
width: 280,
|
||||||
|
height: 220,
|
||||||
|
open: true,
|
||||||
|
checkedItems: [],
|
||||||
|
checklistItems: []
|
||||||
|
};
|
||||||
|
|
||||||
|
this._notes.push(noteData);
|
||||||
|
|
||||||
|
// Widget erstellen
|
||||||
|
const widgetId = WidgetManager.create('note', {
|
||||||
|
id: noteData.id,
|
||||||
|
title: noteData.title,
|
||||||
|
x: noteData.x,
|
||||||
|
y: noteData.y,
|
||||||
|
width: noteData.width,
|
||||||
|
height: noteData.height,
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body rendern
|
||||||
|
const body = WidgetManager.getBody(widgetId);
|
||||||
|
if (body) this.renderBody(noteData, body);
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
return widgetId;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note-Body rendern (in Widget-Body einfuegen)
|
||||||
|
* @param {Object} noteData
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
renderBody(noteData, bodyEl) {
|
||||||
|
bodyEl.textContent = '';
|
||||||
|
if (noteData.template === 'checklist') {
|
||||||
|
this._renderChecklistBody(noteData, bodyEl);
|
||||||
|
} else {
|
||||||
|
this._renderTextBody(noteData, bodyEl);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freitext-Body: Textarea mit Zeichenzaehler
|
||||||
|
* @param {Object} noteData
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
_renderTextBody(noteData, bodyEl) {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'widget-textarea';
|
||||||
|
textarea.placeholder = 'Notiz schreiben...';
|
||||||
|
textarea.spellcheck = false;
|
||||||
|
textarea.value = noteData.content || '';
|
||||||
|
textarea.maxLength = this.MAX_CHARS;
|
||||||
|
|
||||||
|
const counter = document.createElement('span');
|
||||||
|
counter.className = 'widget-char-count';
|
||||||
|
counter.textContent = (noteData.content || '').length + ' / ' + this.MAX_CHARS;
|
||||||
|
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
noteData.content = textarea.value;
|
||||||
|
const len = textarea.value.length;
|
||||||
|
counter.textContent = len + ' / ' + this.MAX_CHARS;
|
||||||
|
counter.classList.toggle('limit', len >= this.MAX_CHARS);
|
||||||
|
|
||||||
|
// Auto-Titel aus erster Zeile
|
||||||
|
const firstLine = textarea.value.split('\n')[0].trim().slice(0, 20);
|
||||||
|
if (firstLine) {
|
||||||
|
noteData.title = firstLine;
|
||||||
|
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||||
|
if (widgetEntry) {
|
||||||
|
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||||
|
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||||
|
titleEl.textContent = firstLine;
|
||||||
|
}
|
||||||
|
widgetEntry.state.title = firstLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._debouncedSave();
|
||||||
|
});
|
||||||
|
|
||||||
|
bodyEl.append(textarea, counter);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checklisten-Body: Items mit Checkboxen
|
||||||
|
* @param {Object} noteData
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
_renderChecklistBody(noteData, bodyEl) {
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
list.className = 'widget-checklist';
|
||||||
|
|
||||||
|
// Bestehende Items rendern
|
||||||
|
if (!Array.isArray(noteData.checklistItems)) {
|
||||||
|
noteData.checklistItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItems = () => {
|
||||||
|
list.textContent = '';
|
||||||
|
noteData.checklistItems.forEach((item, idx) => {
|
||||||
|
const li = this._createChecklistItem(noteData, item, idx, renderItems);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
renderItems();
|
||||||
|
|
||||||
|
// Eingabefeld fuer neue Items
|
||||||
|
const addRow = document.createElement('div');
|
||||||
|
addRow.className = 'checklist-add';
|
||||||
|
|
||||||
|
const addInput = document.createElement('input');
|
||||||
|
addInput.className = 'checklist-add-input';
|
||||||
|
addInput.type = 'text';
|
||||||
|
addInput.placeholder = 'Neues Item...';
|
||||||
|
addInput.maxLength = 100;
|
||||||
|
|
||||||
|
addInput.addEventListener('keydown', async (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const text = addInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
noteData.checklistItems.push({ text, checked: false });
|
||||||
|
addInput.value = '';
|
||||||
|
renderItems();
|
||||||
|
this._updateChecklistContent(noteData);
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addRow.appendChild(addInput);
|
||||||
|
bodyEl.append(list, addRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einzelnes Checklisten-Item erstellen
|
||||||
|
* @param {Object} noteData
|
||||||
|
* @param {Object} item - { text, checked }
|
||||||
|
* @param {number} idx
|
||||||
|
* @param {Function} rerenderFn
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createChecklistItem(noteData, item, idx, rerenderFn) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'checklist-item' + (item.checked ? ' checked' : '');
|
||||||
|
|
||||||
|
const checkbox = document.createElement('span');
|
||||||
|
checkbox.className = 'checklist-checkbox';
|
||||||
|
checkbox.textContent = item.checked ? '\u2713' : '';
|
||||||
|
checkbox.addEventListener('click', async () => {
|
||||||
|
item.checked = !item.checked;
|
||||||
|
li.classList.toggle('checked', item.checked);
|
||||||
|
checkbox.textContent = item.checked ? '\u2713' : '';
|
||||||
|
this._updateChecklistContent(noteData);
|
||||||
|
await this.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.className = 'checklist-text';
|
||||||
|
text.textContent = item.text;
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'checklist-remove';
|
||||||
|
removeBtn.textContent = '\u2715';
|
||||||
|
removeBtn.addEventListener('click', async () => {
|
||||||
|
noteData.checklistItems.splice(idx, 1);
|
||||||
|
rerenderFn();
|
||||||
|
this._updateChecklistContent(noteData);
|
||||||
|
await this.save();
|
||||||
|
});
|
||||||
|
|
||||||
|
li.append(checkbox, text, removeBtn);
|
||||||
|
return li;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checklisten-Content fuer Export/Vorschau aktualisieren
|
||||||
|
* @param {Object} noteData
|
||||||
|
*/
|
||||||
|
_updateChecklistContent(noteData) {
|
||||||
|
const total = noteData.checklistItems.length;
|
||||||
|
const done = noteData.checklistItems.filter(i => i.checked).length;
|
||||||
|
noteData.content = noteData.checklistItems.map(i => (i.checked ? '[x] ' : '[ ] ') + i.text).join('\n');
|
||||||
|
|
||||||
|
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
|
||||||
|
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||||
|
if (widgetEntry) {
|
||||||
|
const defaultTitle = done + '/' + total + ' erledigt';
|
||||||
|
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||||
|
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||||
|
// Nur wenn Titel noch Standard ist
|
||||||
|
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.test(noteData.title)) {
|
||||||
|
noteData.title = defaultTitle;
|
||||||
|
titleEl.textContent = defaultTitle;
|
||||||
|
widgetEntry.state.title = defaultTitle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note anhand ID finden
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
getNote(id) {
|
||||||
|
return this._notes.find(n => n.id === id) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note loeschen
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async deleteNote(id) {
|
||||||
|
const idx = this._notes.findIndex(n => n.id === id);
|
||||||
|
if (idx === -1) return;
|
||||||
|
|
||||||
|
const ok = await HellionDialog.confirm(
|
||||||
|
'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
|
||||||
|
{ type: 'danger', title: 'Note löschen', confirmText: 'Löschen' }
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
this._notes.splice(idx, 1);
|
||||||
|
WidgetManager.close(id);
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note als .md exportieren
|
||||||
|
* @param {Object} noteData
|
||||||
|
*/
|
||||||
|
exportNote(noteData) {
|
||||||
|
let md = '# ' + noteData.title + '\n\n';
|
||||||
|
if (noteData.template === 'checklist') {
|
||||||
|
noteData.checklistItems.forEach(item => {
|
||||||
|
md += (item.checked ? '- [x] ' : '- [ ] ') + item.text + '\n';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
md += noteData.content || '';
|
||||||
|
}
|
||||||
|
md += '\n\n---\n*Exportiert aus Hellion Dashboard*\n';
|
||||||
|
|
||||||
|
const blob = new Blob([md], { type: 'text/markdown' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = (noteData.title || 'note').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_') + '.md';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- NOTEBOOK SIDEBAR ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notebook-Sidebar oeffnen
|
||||||
|
*/
|
||||||
|
openNotebook() {
|
||||||
|
const overlay = document.getElementById('notebookOverlay');
|
||||||
|
const panel = document.getElementById('notebookPanel');
|
||||||
|
if (overlay) overlay.classList.add('active');
|
||||||
|
if (panel) panel.classList.add('open');
|
||||||
|
this._renderNotebookSlots();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notebook-Sidebar schliessen
|
||||||
|
*/
|
||||||
|
closeNotebook() {
|
||||||
|
const overlay = document.getElementById('notebookOverlay');
|
||||||
|
const panel = document.getElementById('notebookPanel');
|
||||||
|
if (overlay) overlay.classList.remove('active');
|
||||||
|
if (panel) panel.classList.remove('open');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notebook-Slots rendern
|
||||||
|
*/
|
||||||
|
_renderNotebookSlots() {
|
||||||
|
const container = document.getElementById('notebookSlots');
|
||||||
|
const countEl = document.getElementById('notebookCount');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.textContent = '';
|
||||||
|
if (countEl) countEl.textContent = this._notes.length + ' / ' + this.MAX_NOTES;
|
||||||
|
|
||||||
|
// Belegte Slots
|
||||||
|
this._notes.forEach(note => {
|
||||||
|
const slot = this._createNotebookSlot(note);
|
||||||
|
container.appendChild(slot);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leere Slots
|
||||||
|
const remaining = this.MAX_NOTES - this._notes.length;
|
||||||
|
for (let i = 0; i < remaining; i++) {
|
||||||
|
const emptySlot = this._createEmptySlot();
|
||||||
|
container.appendChild(emptySlot);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Belegten Notebook-Slot erstellen
|
||||||
|
* @param {Object} note
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createNotebookSlot(note) {
|
||||||
|
const slot = document.createElement('div');
|
||||||
|
slot.className = 'notebook-slot';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'notebook-slot-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'notebook-slot-title';
|
||||||
|
|
||||||
|
const typeIcon = document.createElement('span');
|
||||||
|
typeIcon.className = 'notebook-slot-type';
|
||||||
|
typeIcon.textContent = note.template === 'checklist' ? '\u2611' : '\u270E';
|
||||||
|
title.append(typeIcon);
|
||||||
|
title.append(document.createTextNode(' ' + note.title));
|
||||||
|
|
||||||
|
header.appendChild(title);
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'notebook-slot-preview';
|
||||||
|
if (note.template === 'checklist') {
|
||||||
|
const total = note.checklistItems ? note.checklistItems.length : 0;
|
||||||
|
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
|
||||||
|
preview.textContent = done + '/' + total + ' erledigt';
|
||||||
|
} else {
|
||||||
|
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'notebook-slot-actions';
|
||||||
|
|
||||||
|
const btnExport = document.createElement('button');
|
||||||
|
btnExport.className = 'notebook-slot-btn';
|
||||||
|
btnExport.textContent = 'Export';
|
||||||
|
btnExport.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.exportNote(note);
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnDelete = document.createElement('button');
|
||||||
|
btnDelete.className = 'notebook-slot-btn danger';
|
||||||
|
btnDelete.textContent = '\uD83D\uDDD1';
|
||||||
|
btnDelete.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.deleteNote(note.id);
|
||||||
|
this._renderNotebookSlots();
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.append(btnExport, btnDelete);
|
||||||
|
slot.append(header, preview, actions);
|
||||||
|
|
||||||
|
// Klick oeffnet Note als Widget
|
||||||
|
slot.addEventListener('click', async () => {
|
||||||
|
if (WidgetManager.isOpen(note.id)) {
|
||||||
|
WidgetManager.bringToFront(note.id);
|
||||||
|
} else {
|
||||||
|
await WidgetManager.openWidget(note.id);
|
||||||
|
}
|
||||||
|
this.closeNotebook();
|
||||||
|
});
|
||||||
|
|
||||||
|
return slot;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leeren Notebook-Slot erstellen
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createEmptySlot() {
|
||||||
|
const slot = document.createElement('div');
|
||||||
|
slot.className = 'notebook-slot-empty';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.textContent = '+ Note erstellen';
|
||||||
|
slot.appendChild(label);
|
||||||
|
|
||||||
|
// Klick zeigt Typ-Auswahl
|
||||||
|
let chooserOpen = false;
|
||||||
|
slot.addEventListener('click', () => {
|
||||||
|
if (chooserOpen) return;
|
||||||
|
chooserOpen = true;
|
||||||
|
label.style.display = 'none';
|
||||||
|
|
||||||
|
const chooser = document.createElement('div');
|
||||||
|
chooser.className = 'notebook-type-chooser';
|
||||||
|
|
||||||
|
const btnText = document.createElement('button');
|
||||||
|
btnText.className = 'notebook-type-btn';
|
||||||
|
btnText.textContent = '\u270E Freitext';
|
||||||
|
btnText.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.create('text');
|
||||||
|
this._renderNotebookSlots();
|
||||||
|
});
|
||||||
|
|
||||||
|
const btnCheck = document.createElement('button');
|
||||||
|
btnCheck.className = 'notebook-type-btn';
|
||||||
|
btnCheck.textContent = '\u2611 Checkliste';
|
||||||
|
btnCheck.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await this.create('checklist');
|
||||||
|
this._renderNotebookSlots();
|
||||||
|
});
|
||||||
|
|
||||||
|
chooser.append(btnText, btnCheck);
|
||||||
|
slot.appendChild(chooser);
|
||||||
|
});
|
||||||
|
|
||||||
|
return slot;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- TOOLBAR EVENTS ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget-Toolbar initialisieren
|
||||||
|
*/
|
||||||
|
initToolbar() {
|
||||||
|
const toolbar = document.getElementById('widgetToolbar');
|
||||||
|
if (!toolbar) return;
|
||||||
|
|
||||||
|
toolbar.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.widget-toolbar-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'new-note') {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- INIT ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notes-System initialisieren (ersetzt initStickyNote)
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
// Widgets wiederherstellen
|
||||||
|
await WidgetManager.restore((noteData, bodyEl) => {
|
||||||
|
this.renderBody(noteData, bodyEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toolbar initialisieren
|
||||||
|
this.initToolbar();
|
||||||
|
|
||||||
|
// Notebook-Sidebar Events
|
||||||
|
const notebookOverlay = document.getElementById('notebookOverlay');
|
||||||
|
if (notebookOverlay) {
|
||||||
|
notebookOverlay.addEventListener('click', () => this.closeNotebook());
|
||||||
|
}
|
||||||
|
const btnCloseNotebook = document.getElementById('btnCloseNotebook');
|
||||||
|
if (btnCloseNotebook) {
|
||||||
|
btnCloseNotebook.addEventListener('click', () => this.closeNotebook());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header btnNote oeffnet Notebook
|
||||||
|
const btnNote = document.getElementById('btnNote');
|
||||||
|
if (btnNote) {
|
||||||
|
btnNote.addEventListener('click', () => this.openNotebook());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -24,17 +24,20 @@ const Onboarding = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\uD83C\uDFA8',
|
hero: '\uD83C\uDFA8',
|
||||||
title: '8 handgefertigte Themes',
|
title: '11 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
|
showThemes: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hero: '\u26A1',
|
hero: '\uD83E\uDDF0',
|
||||||
title: 'Weitere Features',
|
title: 'Widget-Toolbar',
|
||||||
features: [
|
features: [
|
||||||
'Suchleiste mit Google, DuckDuckGo oder Bing',
|
'Die schwebenden Buttons rechts \u00F6ffnen Widgets',
|
||||||
'Sticky Notes f\u00FCr schnelle Notizen',
|
'Notes und Checklisten f\u00FCr schnelle Notizen',
|
||||||
'Funktioniert komplett offline \u2014 alles lokal gespeichert'
|
'Taschenrechner mit History',
|
||||||
|
'Timer/Countdown mit speicherbaren Presets',
|
||||||
|
'Bild-Referenz Widgets (aktivierbar in Settings)',
|
||||||
|
'Notebook-Sidebar zeigt alle Notes auf einen Blick'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,10 +45,16 @@ const Onboarding = {
|
|||||||
title: 'Backups nicht vergessen!',
|
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.'
|
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',
|
hero: '\uD83D\uDE80',
|
||||||
title: 'Bereit!',
|
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!'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -118,7 +127,7 @@ const Onboarding = {
|
|||||||
if (slide.showThemes) {
|
if (slide.showThemes) {
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'onboarding-theme-grid';
|
grid.className = 'onboarding-theme-grid';
|
||||||
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy'];
|
const themeNames = ['Nebula', 'Crescent', 'Event Horizon', 'Merchantman', 'Julia & Jin', 'SC Sunset', 'Hellion HUD', 'Hellion Energy', 'Satisfactory', 'Avorion', 'Hellion Stealth'];
|
||||||
themeNames.forEach(name => {
|
themeNames.forEach(name => {
|
||||||
const chip = document.createElement('div');
|
const chip = document.createElement('div');
|
||||||
chip.className = 'onboarding-theme-chip';
|
chip.className = 'onboarding-theme-chip';
|
||||||
@@ -159,7 +168,27 @@ const Onboarding = {
|
|||||||
nav.appendChild(backBtn);
|
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');
|
const startBtn = document.createElement('button');
|
||||||
startBtn.className = 'btn-primary';
|
startBtn.className = 'btn-primary';
|
||||||
startBtn.textContent = 'Los geht\u2019s!';
|
startBtn.textContent = 'Los geht\u2019s!';
|
||||||
@@ -180,6 +209,34 @@ const Onboarding = {
|
|||||||
modal.appendChild(footer);
|
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 */
|
/** Keyboard-Navigation */
|
||||||
_bindKeyboard() {
|
_bindKeyboard() {
|
||||||
this._keyHandler = (e) => {
|
this._keyHandler = (e) => {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function closeThemeModal() {
|
|||||||
|
|
||||||
// ---- ACCORDION ----
|
// ---- ACCORDION ----
|
||||||
function initAccordion() {
|
function initAccordion() {
|
||||||
const defaultOpen = new Set(['appearance', 'behavior', 'data', 'help']);
|
const defaultOpen = new Set(['widgets']);
|
||||||
const sections = document.querySelectorAll('.settings-section[data-section]');
|
const sections = document.querySelectorAll('.settings-section[data-section]');
|
||||||
|
|
||||||
sections.forEach(section => {
|
sections.forEach(section => {
|
||||||
@@ -71,6 +71,18 @@ function applySettings() {
|
|||||||
const showSearchEl = document.getElementById('settingShowSearch');
|
const showSearchEl = document.getElementById('settingShowSearch');
|
||||||
if (showSearchEl) showSearchEl.checked = settings.showSearch;
|
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');
|
||||||
|
if (toolbarPosEl) toolbarPosEl.value = settings.toolbarPos || 'right';
|
||||||
|
|
||||||
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
applyTheme(settings.theme || 'nebula', !!settings.bgUrl);
|
||||||
|
|
||||||
if (settings.bgUrl) {
|
if (settings.bgUrl) {
|
||||||
@@ -122,6 +134,11 @@ function bindSettingsEvents() {
|
|||||||
settingShowSearch: v => {
|
settingShowSearch: v => {
|
||||||
settings.showSearch = v;
|
settings.showSearch = v;
|
||||||
document.getElementById('searchBarWrapper').classList.toggle('hidden', !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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -172,6 +189,17 @@ function bindSettingsEvents() {
|
|||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Toolbar-Position Setting
|
||||||
|
const toolbarPosEl = document.getElementById('settingToolbarPos');
|
||||||
|
if (toolbarPosEl) {
|
||||||
|
toolbarPosEl.value = settings.toolbarPos || 'right';
|
||||||
|
toolbarPosEl.addEventListener('change', async (e) => {
|
||||||
|
settings.toolbarPos = e.target.value;
|
||||||
|
document.body.classList.toggle('toolbar-left', e.target.value === 'left');
|
||||||
|
await saveSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Onboarding wiederholen
|
// Onboarding wiederholen
|
||||||
document.getElementById('btnRestartOnboarding').addEventListener('click', () => {
|
document.getElementById('btnRestartOnboarding').addEventListener('click', () => {
|
||||||
closeSettings();
|
closeSettings();
|
||||||
@@ -188,7 +216,8 @@ function bindSettingsEvents() {
|
|||||||
boards = [];
|
boards = [];
|
||||||
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false,
|
||||||
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula',
|
||||||
showSearch: true, searchEngine: 'google' };
|
showSearch: true, searchEngine: 'google', toolbarPos: 'right',
|
||||||
|
imageRefEnabled: false };
|
||||||
await saveBoards();
|
await saveBoards();
|
||||||
await saveSettings();
|
await saveSettings();
|
||||||
applySettings();
|
applySettings();
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ let settings = {
|
|||||||
bgUrl: '',
|
bgUrl: '',
|
||||||
theme: 'nebula',
|
theme: 'nebula',
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
searchEngine: 'google'
|
searchEngine: 'google',
|
||||||
|
toolbarPos: 'right',
|
||||||
|
imageRefEnabled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
function uid() {
|
function uid() {
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
============================================= */
|
============================================= */
|
||||||
|
|
||||||
const THEMES = {
|
const THEMES = {
|
||||||
'nebula': { bg: 'assets/themes/bg-nebula.jpg' },
|
'nebula': { bg: 'assets/themes/bg-nebula.webp' },
|
||||||
'crescent': { bg: 'assets/themes/bg-crescent.jpg' },
|
'crescent': { bg: 'assets/themes/bg-crescent.webp' },
|
||||||
'event-horizon': { bg: 'assets/themes/bg-event-horizon.jpg' },
|
'event-horizon': { bg: 'assets/themes/bg-event-horizon.webp' },
|
||||||
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
|
'merchantman': { bg: 'assets/themes/bg-merchantman.webp' },
|
||||||
'julia-jin': { bg: 'assets/themes/bg-julia-jin.png' },
|
'julia-jin': { bg: 'assets/themes/bg-julia-jin.webp' },
|
||||||
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.jpg' },
|
'sc-sunset': { bg: 'assets/themes/bg-sc-sunset.webp' },
|
||||||
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.png' },
|
'hellion-hud': { bg: 'assets/themes/bg-hellion-hud.webp' },
|
||||||
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.jpg' }
|
'hellion-energy': { bg: 'assets/themes/bg-hellion-energy.webp' },
|
||||||
|
'satisfactory': { bg: 'assets/themes/bg-satisfactory.webp' },
|
||||||
|
'avorion': { bg: 'assets/themes/bg-avorion.webp' },
|
||||||
|
'hellion-stealth': { bg: 'assets/themes/bg-scPolaris.webp' }
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyTheme(themeName, skipBgOverride) {
|
function applyTheme(themeName, skipBgOverride) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — widgets.js
|
||||||
|
Widget-Manager: Registry, Drag, Resize, Z-Index, Persistierung
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const WidgetManager = {
|
||||||
|
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
|
||||||
|
_widgets: new Map(),
|
||||||
|
_topZ: 100,
|
||||||
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget erstellen und in DOM einfuegen
|
||||||
|
* @param {string} type - 'note'
|
||||||
|
* @param {Object} config - { id, title, x, y, width, height, open }
|
||||||
|
* @returns {string} widget-id
|
||||||
|
*/
|
||||||
|
create(type, config) {
|
||||||
|
const id = config.id || ('widget_' + uid());
|
||||||
|
const state = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
title: config.title || 'Note',
|
||||||
|
x: config.x || 120,
|
||||||
|
y: config.y || 80,
|
||||||
|
width: config.width || 280,
|
||||||
|
height: config.height || 220,
|
||||||
|
open: config.open !== false
|
||||||
|
};
|
||||||
|
|
||||||
|
const el = this._buildDOM(state);
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
this._widgets.set(id, { el, type, state });
|
||||||
|
this._initDrag(el);
|
||||||
|
this._initResize(el);
|
||||||
|
this.bringToFront(id);
|
||||||
|
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget-DOM erzeugen (createElement, kein innerHTML)
|
||||||
|
* @param {Object} state
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_buildDOM(state) {
|
||||||
|
const widget = document.createElement('div');
|
||||||
|
widget.className = 'widget';
|
||||||
|
widget.dataset.widgetId = state.id;
|
||||||
|
widget.style.left = state.x + 'px';
|
||||||
|
widget.style.top = state.y + 'px';
|
||||||
|
widget.style.width = state.width + 'px';
|
||||||
|
widget.style.height = state.height + 'px';
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'widget-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'widget-title';
|
||||||
|
title.textContent = state.title;
|
||||||
|
|
||||||
|
// Doppelklick auf Titel zum Editieren
|
||||||
|
title.addEventListener('dblclick', () => {
|
||||||
|
title.contentEditable = 'true';
|
||||||
|
title.focus();
|
||||||
|
// Text selektieren
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(title);
|
||||||
|
const sel = window.getSelection();
|
||||||
|
sel.removeAllRanges();
|
||||||
|
sel.addRange(range);
|
||||||
|
});
|
||||||
|
title.addEventListener('blur', async () => {
|
||||||
|
title.contentEditable = 'false';
|
||||||
|
const newTitle = title.textContent.trim().slice(0, 20);
|
||||||
|
title.textContent = newTitle || 'Note';
|
||||||
|
const entry = this._widgets.get(state.id);
|
||||||
|
if (entry) {
|
||||||
|
entry.state.title = title.textContent;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
title.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
title.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'widget-actions';
|
||||||
|
|
||||||
|
const btnMin = document.createElement('button');
|
||||||
|
btnMin.className = 'widget-btn widget-minimize';
|
||||||
|
btnMin.title = 'Minimieren';
|
||||||
|
btnMin.textContent = '\u2500';
|
||||||
|
btnMin.addEventListener('click', () => this.minimize(state.id));
|
||||||
|
|
||||||
|
const btnClose = document.createElement('button');
|
||||||
|
btnClose.className = 'widget-btn widget-close';
|
||||||
|
btnClose.title = 'Schließen';
|
||||||
|
btnClose.textContent = '\u2715';
|
||||||
|
btnClose.addEventListener('click', () => this.close(state.id));
|
||||||
|
|
||||||
|
actions.append(btnMin, btnClose);
|
||||||
|
header.append(title, actions);
|
||||||
|
|
||||||
|
// Body
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'widget-body';
|
||||||
|
|
||||||
|
// Resize Handle
|
||||||
|
const resizeHandle = document.createElement('div');
|
||||||
|
resizeHandle.className = 'widget-resize-handle';
|
||||||
|
|
||||||
|
widget.append(header, body, resizeHandle);
|
||||||
|
|
||||||
|
// Klick auf Widget bringt es nach vorne
|
||||||
|
widget.addEventListener('pointerdown', () => {
|
||||||
|
this.bringToFront(state.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return widget;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget-Body-Element holen
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
getBody(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return null;
|
||||||
|
return entry.el.querySelector('.widget-body');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget entfernen (endgueltig loeschen)
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
close(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.el.remove();
|
||||||
|
this._widgets.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget minimieren (aus DOM verstecken, bleibt im Notebook)
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async minimize(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state.open = false;
|
||||||
|
entry.el.classList.add('widget-minimized');
|
||||||
|
setTimeout(() => {
|
||||||
|
entry.el.style.display = 'none';
|
||||||
|
}, 250);
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget oeffnen (aus minimiertem Zustand wiederherstellen)
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
async openWidget(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
entry.state.open = true;
|
||||||
|
entry.el.style.display = 'flex';
|
||||||
|
// Naechster Frame fuer Animation
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
entry.el.classList.remove('widget-minimized');
|
||||||
|
});
|
||||||
|
this.bringToFront(id);
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget in den Vordergrund bringen
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
bringToFront(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
this._topZ++;
|
||||||
|
entry.el.style.zIndex = this._topZ;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag initialisieren (Pointer Events auf Header)
|
||||||
|
* @param {HTMLElement} widgetEl
|
||||||
|
*/
|
||||||
|
_initDrag(widgetEl) {
|
||||||
|
const header = widgetEl.querySelector('.widget-header');
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
header.addEventListener('pointerdown', function onDown(e) {
|
||||||
|
if (e.target.closest('.widget-btn') || e.target.closest('.widget-title[contenteditable="true"]')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
header.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
const rect = widgetEl.getBoundingClientRect();
|
||||||
|
const offX = e.clientX - rect.left;
|
||||||
|
const offY = e.clientY - rect.top;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
const maxX = window.innerWidth - widgetEl.offsetWidth;
|
||||||
|
const maxY = window.innerHeight - widgetEl.offsetHeight;
|
||||||
|
widgetEl.style.left = Math.max(0, Math.min(maxX, ev.clientX - offX)) + 'px';
|
||||||
|
widgetEl.style.top = Math.max(48, Math.min(maxY, ev.clientY - offY)) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUp() {
|
||||||
|
header.releasePointerCapture(e.pointerId);
|
||||||
|
header.removeEventListener('pointermove', onMove);
|
||||||
|
header.removeEventListener('pointerup', onUp);
|
||||||
|
// State aktualisieren
|
||||||
|
const id = widgetEl.dataset.widgetId;
|
||||||
|
const entry = self._widgets.get(id);
|
||||||
|
if (entry) {
|
||||||
|
entry.state.x = parseFloat(widgetEl.style.left);
|
||||||
|
entry.state.y = parseFloat(widgetEl.style.top);
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header.addEventListener('pointermove', onMove);
|
||||||
|
header.addEventListener('pointerup', onUp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize initialisieren (Pointer Events auf Handle)
|
||||||
|
* @param {HTMLElement} widgetEl
|
||||||
|
*/
|
||||||
|
_initResize(widgetEl) {
|
||||||
|
const handle = widgetEl.querySelector('.widget-resize-handle');
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
handle.addEventListener('pointerdown', function onDown(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handle.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
const startW = widgetEl.offsetWidth;
|
||||||
|
const startH = widgetEl.offsetHeight;
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
|
||||||
|
function onMove(ev) {
|
||||||
|
widgetEl.style.width = Math.max(200, startW + (ev.clientX - startX)) + 'px';
|
||||||
|
widgetEl.style.height = Math.max(150, startH + (ev.clientY - startY)) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUp() {
|
||||||
|
handle.releasePointerCapture(e.pointerId);
|
||||||
|
handle.removeEventListener('pointermove', onMove);
|
||||||
|
handle.removeEventListener('pointerup', onUp);
|
||||||
|
const id = widgetEl.dataset.widgetId;
|
||||||
|
const entry = self._widgets.get(id);
|
||||||
|
if (entry) {
|
||||||
|
entry.state.width = widgetEl.offsetWidth;
|
||||||
|
entry.state.height = widgetEl.offsetHeight;
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.addEventListener('pointermove', onMove);
|
||||||
|
handle.addEventListener('pointerup', onUp);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Widget-States aus Storage laden und wiederherstellen
|
||||||
|
* @param {Function} renderCallback - Funktion die den Body rendert (noteData, bodyEl)
|
||||||
|
*/
|
||||||
|
async restore(renderCallback) {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (!data || !Array.isArray(data.notes)) return;
|
||||||
|
|
||||||
|
for (const noteData of data.notes) {
|
||||||
|
const id = this.create('note', {
|
||||||
|
id: noteData.id,
|
||||||
|
title: noteData.title,
|
||||||
|
x: noteData.x,
|
||||||
|
y: noteData.y,
|
||||||
|
width: noteData.width,
|
||||||
|
height: noteData.height,
|
||||||
|
open: noteData.open
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body rendern lassen (von Notes-Modul)
|
||||||
|
if (renderCallback) {
|
||||||
|
const body = this.getBody(id);
|
||||||
|
if (body) renderCallback(noteData, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Falls minimiert, sofort verstecken
|
||||||
|
if (!noteData.open) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
if (entry) {
|
||||||
|
entry.el.classList.add('widget-minimized');
|
||||||
|
entry.el.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Widget-States speichern
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
const notes = [];
|
||||||
|
for (const [id, entry] of this._widgets) {
|
||||||
|
if (entry.type === 'note') {
|
||||||
|
notes.push({
|
||||||
|
...entry.state,
|
||||||
|
// Zusaetzliche Note-Daten werden von Notes.save() ergaenzt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nicht direkt speichern — Notes-Modul merged die Daten
|
||||||
|
return notes;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Widget-State fuer eine bestimmte ID holen
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
getState(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
return entry ? entry.state : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pruefen ob Widget offen ist
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isOpen(id) {
|
||||||
|
const entry = this._widgets.get(id);
|
||||||
|
return entry ? entry.state.open : false;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anzahl aller Widgets
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
count() {
|
||||||
|
return this._widgets.size;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alle Widget-IDs
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getAllIds() {
|
||||||
|
return Array.from(this._widgets.keys());
|
||||||
|
}
|
||||||
|
};
|
||||||