6 Commits

Author SHA1 Message Date
JonKazama-Hellion f08d5d7563 feat(image-ref): Bild-Referenz Widget mit Session-Storage
Opt-in Widget fuer Bild-Referenzen (max. 3 gleichzeitig).
Canvas API konvertiert zu WebP, sessionStorage fuer Bilddaten.
Positionen und Labels bleiben persistent, Bilder nur pro Session.
2026-03-22 00:47:51 +01:00
JonKazama-Hellion b55bb7ac34 feat(timer): Timer/Countdown-Widget mit Presets und Alarm
Countdown-Timer als Single-Instance-Widget mit Preset-System
(max. 5), Web Audio API Alarm und Tab-Titel-Blink bei Ablauf.
Mute-Toggle zum Stummschalten des Alarms.
Z-Index-Hierarchie für Widgets auf 100 angehoben.
2026-03-22 00:32:41 +01:00
JonKazama-Hellion 37e45a2041 feat(calculator): Taschenrechner-Widget mit History und Tastatureingabe
Neues Widget-Modul mit Shunting-Yard Parser, 4x5 Button-Grid,
persistenter History (max 10) und Keyboard-Support.
Storage-Handling in Notes/Data erweitert fuer parallele Persistierung.
2026-03-22 00:13:40 +01:00
JonKazama-Hellion 18a04b884c refactor(app): Sticky-Note durch Widget-System ersetzen
Migration alter Sticky-Daten in das neue Widget-System, Notes.init()
statt initStickyNote(), Toolbar-Position in Settings, JSON-Export/Import
um Notes erweitert, Onboarding-Text aktualisiert.
2026-03-21 19:40:43 +01:00
JonKazama-Hellion 7a16462358 feat(widgets): Widget-System mit Notes, Checklisten und Notebook-Sidebar
Neues modulares Widget-System als Ersatz für die alte Sticky Note.
Widget-Manager (Drag, Resize, Z-Index, Persistierung), Freitext-Notes
mit Zeichenzähler, Checklisten mit Toggle/Add/Remove, Notebook-Sidebar
mit 5 Slots, Widget-Toolbar am rechten Rand.
2026-03-21 19:40:26 +01:00
JonKazama-Hellion ed11827321 chore(meta): Ko-fi-Link einbauen und v2-Planung aus Git ausschliessen
- README: Ko-fi Badge und Link in Impressum-Tabelle
- newtab.html: Ko-fi-Link in About-Section mit Kaffeetassen-Icon
- .gitignore: v2-planning.md ausschliessen
2026-03-21 19:17:07 +01:00
14 changed files with 3862 additions and 42 deletions
+1
View File
@@ -15,6 +15,7 @@ dist/
*.tar.gz *.tar.gz
node_modules/ node_modules/
/xpi/ /xpi/
v2-planning.md
# Persönliche Backup-Dateien (nicht ins Repo) # Persönliche Backup-Dateien (nicht ins Repo)
favorites_*.html favorites_*.html
+2
View File
@@ -5,6 +5,7 @@
![Manifest](https://img.shields.io/badge/Manifest-V3-green) ![Manifest](https://img.shields.io/badge/Manifest-V3-green)
![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange) ![License](https://img.shields.io/badge/License-CC%20BY--NC--SA%204.0-orange)
![Privacy](https://img.shields.io/badge/Privacy-100%25%20Lokal-448f45) ![Privacy](https://img.shields.io/badge/Privacy-100%25%20Lokal-448f45)
[![Ko-fi](https://img.shields.io/badge/Support-Ko--fi-ff5e5b?logo=ko-fi)](https://ko-fi.com/hellionmedia)
**Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.** **Kein Account. Kein Abo. Keine Cloud. Alle Daten bleiben 100% lokal.**
@@ -339,6 +340,7 @@ Vollständige Lizenz: [LICENSE](LICENSE) | [CC BY-NC-SA 4.0](https://creativecom
| **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) | | **Impressum** | [hellion-media.de/impressum](https://hellion-media.de/impressum) |
| **Bug Reports** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Bug%20Report) | | **Bug Reports** | [kontakt@hellion-media.de](mailto:kontakt@hellion-media.de?subject=Hellion%20NewTab%20%E2%80%93%20Bug%20Report) |
| **Security** | [SECURITY.md](SECURITY.md) | | **Security** | [SECURITY.md](SECURITY.md) |
| **Support** | [Ko-fi](https://ko-fi.com/hellionmedia) |
--- ---
+75 -11
View File
@@ -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 -->
@@ -161,6 +183,36 @@
</div> </div>
</section> </section>
<!-- WIDGETS -->
<section class="settings-section" data-section="widgets">
<button class="settings-section-title" type="button">
<span class="section-chevron"></span>
WIDGETS
</button>
<div class="section-content">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Toolbar-Position</span>
<span class="setting-desc">Widget-Toolbar links oder rechts</span>
</div>
<select class="select-input" id="settingToolbarPos">
<option value="right" selected>Rechts</option>
<option value="left">Links</option>
</select>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">Bild-Referenz Widgets</span>
<span class="setting-desc">Bilder als Referenz anzeigen (nur aktuelle Session)</span>
</div>
<label class="toggle">
<input type="checkbox" id="settingImageRef">
<span class="slider"></span>
</label>
</div>
</div>
</section>
<!-- DATA --> <!-- DATA -->
<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">
@@ -254,6 +306,14 @@
</a> </a>
</div> </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"> <div class="about-browsers">
<span class="about-info-label about-info-label-block">Kompatible Browser</span> <span class="about-info-label about-info-label-block">Kompatible Browser</span>
<div class="about-browser-tags"> <div class="about-browser-tags">
@@ -432,7 +492,11 @@
<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/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>
+702 -21
View File
@@ -904,62 +904,149 @@ body.show-desc .bm-desc { display: block; }
.search-submit:hover { color: var(--accent); } .search-submit:hover { color: var(--accent); }
/* ============================================ /* ============================================
STICKY NOTE WIDGET SYSTEM
============================================ */ ============================================ */
.sticky-note { .widget {
position: fixed; position: fixed;
bottom: 24px; right: 24px; z-index: 100;
z-index: 50; min-width: 200px; min-height: 150px;
width: 240px;
background: var(--bg-board); background: var(--bg-board);
border: 1px solid var(--border-accent); border: 1px solid var(--border-accent);
border-radius: var(--radius); border-radius: var(--radius);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
box-shadow: 0 8px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--accent-dim); box-shadow: 0 8px 40px rgba(0,0,0,0.5), 0 0 0 1px var(--accent-dim);
display: flex; flex-direction: column; display: flex; flex-direction: column;
transition: opacity 0.25s, transform 0.25s, border-color 0.5s; transition: border-color 0.5s;
}
.widget.widget-minimized {
opacity: 0; transform: translateY(8px) scale(0.97); opacity: 0; transform: translateY(8px) scale(0.97);
pointer-events: none; pointer-events: none;
} transition: opacity 0.25s, transform 0.25s;
.sticky-note.visible {
opacity: 1; transform: translateY(0) scale(1);
pointer-events: all;
} }
.sticky-note-header { .widget-header {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
padding: 7px 10px 6px; padding: 7px 10px 6px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
cursor: move; cursor: grab;
user-select: none; user-select: none;
} }
.sticky-note-title { .widget-header:active { cursor: grabbing; }
.widget-title {
display: flex; align-items: center; gap: 5px; display: flex; align-items: center; gap: 5px;
font-family: var(--font-display); font-size: 11px; font-weight: 600; font-family: var(--font-display); font-size: 11px; font-weight: 600;
letter-spacing: 1.5px; text-transform: uppercase; letter-spacing: 1.5px; text-transform: uppercase;
color: var(--accent); color: var(--accent);
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
.sticky-note-close { .widget-title[contenteditable="true"] {
outline: 1px solid var(--border-accent);
border-radius: 2px;
padding: 0 3px;
cursor: text;
}
.widget-actions {
display: flex; align-items: center; gap: 2px;
}
.widget-btn {
background: none; border: none; background: none; border: none;
color: var(--text-muted); font-size: 11px; color: var(--text-muted); font-size: 11px;
cursor: pointer; padding: 1px 4px; border-radius: 3px; cursor: pointer; padding: 1px 4px; border-radius: 3px;
transition: all 0.15s; transition: all 0.15s;
line-height: 1;
} }
.sticky-note-close:hover { background: rgba(255,255,255,0.07); color: var(--text-primary); } .widget-btn:hover { background: rgba(255,255,255,0.07); color: var(--text-primary); }
.sticky-note-body { .widget-body {
flex: 1;
display: flex; flex-direction: column;
overflow: hidden;
position: relative;
}
/* Widget Textarea (Freitext-Note) */
.widget-textarea {
flex: 1; flex: 1;
padding: 10px; padding: 10px;
background: none; border: none; outline: none; resize: none; background: none; border: none; outline: none; resize: none;
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-body); font-size: 12px; line-height: 1.6; font-family: var(--font-body); font-size: 12px; line-height: 1.6;
min-height: 120px; max-height: 300px;
scrollbar-width: thin; scrollbar-color: var(--border) transparent; scrollbar-width: thin; scrollbar-color: var(--border) transparent;
} }
.sticky-note-body::placeholder { color: var(--text-muted); } .widget-textarea::placeholder { color: var(--text-muted); }
.widget-char-count {
position: absolute; bottom: 4px; right: 8px;
font-family: var(--font-body); font-size: 10px;
color: var(--text-muted); pointer-events: none;
}
.widget-char-count.limit { color: var(--danger); }
/* Resize-handle unten rechts */ /* Widget Checkliste */
.sticky-note::after { .widget-checklist {
flex: 1;
list-style: none; margin: 0; padding: 6px 10px;
overflow-y: auto;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
.checklist-item {
display: flex; align-items: center; gap: 8px;
padding: 4px 0;
font-family: var(--font-body); font-size: 12px;
color: var(--text-primary);
border-bottom: 1px solid rgba(255,255,255,0.03);
}
.checklist-item.checked .checklist-text {
text-decoration: line-through;
color: var(--text-muted);
}
.checklist-checkbox {
width: 14px; height: 14px;
border: 1px solid var(--border-accent);
border-radius: 3px;
background: rgba(255,255,255,0.03);
cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: var(--accent); font-size: 10px;
transition: all 0.15s;
}
.checklist-checkbox:hover { border-color: var(--accent); }
.checklist-item.checked .checklist-checkbox {
background: var(--accent-dim);
border-color: var(--accent);
}
.checklist-text {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.checklist-remove {
background: none; border: none;
color: var(--text-muted); font-size: 10px;
cursor: pointer; padding: 0 2px;
opacity: 0; transition: opacity 0.15s;
}
.checklist-item:hover .checklist-remove { opacity: 1; }
.checklist-remove:hover { color: var(--danger); }
.checklist-add {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
border-top: 1px solid var(--border);
}
.checklist-add-input {
flex: 1;
background: none; border: none; outline: none;
color: var(--text-primary);
font-family: var(--font-body); font-size: 12px;
}
.checklist-add-input::placeholder { color: var(--text-muted); }
/* Widget Resize Handle */
.widget-resize-handle {
position: absolute; bottom: 0; right: 0;
width: 14px; height: 14px;
cursor: nwse-resize;
}
.widget-resize-handle::after {
content: ''; content: '';
position: absolute; bottom: 4px; right: 4px; position: absolute; bottom: 4px; right: 4px;
width: 8px; height: 8px; width: 8px; height: 8px;
@@ -968,6 +1055,591 @@ body.show-desc .bm-desc { display: block; }
border-radius: 0 0 2px 0; border-radius: 0 0 2px 0;
opacity: 0.5; opacity: 0.5;
} }
/* ============================================
CALCULATOR WIDGET
============================================ */
.calc-display {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin-bottom: 8px;
text-align: right;
min-height: 48px;
}
.calc-expression {
font-size: 12px;
color: var(--text-muted);
min-height: 16px;
word-break: break-all;
}
.calc-result {
font-size: 22px;
font-family: 'Rajdhani', monospace;
color: var(--text-primary);
font-weight: 600;
word-break: break-all;
}
.calc-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
margin-bottom: 8px;
}
.calc-btn {
height: 36px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 14px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.calc-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--border-accent);
}
.calc-btn:active {
transform: scale(0.95);
}
.calc-btn.operator {
color: var(--accent);
font-weight: 600;
}
.calc-btn.equals {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
.calc-btn.equals:hover {
background: var(--accent);
color: var(--bg-primary);
}
.calc-btn.clear {
color: var(--danger);
}
.calc-history {
border-top: 1px solid var(--border);
padding-top: 6px;
max-height: 100px;
overflow-y: auto;
}
.calc-history-title {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
margin-bottom: 4px;
}
.calc-history-item {
font-size: 11px;
color: var(--text-secondary);
padding: 3px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
justify-content: space-between;
}
.calc-history-item:hover {
background: rgba(255,255,255,0.04);
}
.calc-history-item .calc-h-result {
color: var(--accent);
font-weight: 600;
}
/* ============================================
TIMER WIDGET
============================================ */
.timer-display {
text-align: center;
padding: 16px 0 8px;
}
.timer-time {
font-size: 36px;
font-family: 'Rajdhani', monospace;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 2px;
}
.timer-time.finished {
color: var(--danger);
animation: timer-pulse 1s ease-in-out infinite;
}
@keyframes timer-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.timer-input-row {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.timer-input {
width: 120px;
text-align: center;
font-family: 'Rajdhani', monospace;
font-size: 16px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
padding: 6px 10px;
}
.timer-input::placeholder { color: var(--text-muted); }
.timer-input:focus {
outline: none;
border-color: var(--accent);
}
.timer-controls {
display: flex;
gap: 6px;
margin-bottom: 12px;
padding: 0 8px;
}
.timer-ctrl-btn {
flex: 1;
height: 32px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 12px;
font-family: 'Rajdhani', sans-serif;
cursor: pointer;
transition: all 0.1s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.timer-ctrl-btn:hover {
background: rgba(255,255,255,0.08);
border-color: var(--border-accent);
}
.timer-ctrl-btn:active { transform: scale(0.95); }
.timer-ctrl-btn:disabled {
opacity: 0.3;
cursor: default;
transform: none;
}
.timer-ctrl-btn.primary {
background: var(--accent-dim);
border-color: var(--accent);
color: var(--accent);
font-weight: 600;
}
.timer-ctrl-btn.primary:hover {
background: var(--accent);
color: var(--bg-primary);
}
.timer-ctrl-btn.danger {
color: var(--danger);
}
.timer-mute-btn {
width: 32px;
height: 32px;
flex: none;
font-size: 14px;
line-height: 1;
padding: 0;
background: var(--bg-board);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
}
.timer-mute-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.timer-mute-btn.muted {
color: var(--text-muted);
opacity: 0.5;
}
.timer-presets {
border-top: 1px solid var(--border);
padding: 8px 8px 0;
max-height: 140px;
overflow-y: auto;
}
.timer-presets-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.timer-presets-title {
font-size: 9px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
}
.timer-preset-add {
width: 22px; height: 22px;
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.timer-preset-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.timer-preset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12px;
color: var(--text-secondary);
}
.timer-preset-item:hover {
background: rgba(255,255,255,0.04);
}
.timer-preset-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-preset-time {
color: var(--accent);
font-family: 'Rajdhani', monospace;
margin: 0 8px;
}
.timer-preset-del {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 11px;
padding: 2px 4px;
}
.timer-preset-del:hover { color: var(--danger); }
.timer-add-row {
display: flex;
gap: 4px;
margin-top: 6px;
}
.timer-add-input {
flex: 1;
background: rgba(0,0,0,0.2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 11px;
padding: 4px 6px;
}
.timer-add-input::placeholder { color: var(--text-muted); }
.timer-add-input:focus { outline: none; border-color: var(--accent); }
.timer-add-confirm {
background: var(--accent-dim);
border: 1px solid var(--accent);
border-radius: var(--radius-sm);
color: var(--accent);
cursor: pointer;
font-size: 11px;
padding: 4px 8px;
}
.timer-add-confirm:hover {
background: var(--accent);
color: var(--bg-primary);
}
/* ============================================
IMAGE REFERENCE WIDGET
============================================ */
.imgref-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 8px;
}
.imgref-img-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: var(--radius-sm);
min-height: 80px;
}
.imgref-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius-sm);
}
.imgref-dropzone {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition: border-color 0.2s, color 0.2s;
min-height: 80px;
gap: 8px;
}
.imgref-dropzone:hover,
.imgref-dropzone.dragover {
border-color: var(--accent);
color: var(--text-secondary);
}
.imgref-dropzone-icon {
font-size: 24px;
opacity: 0.5;
}
.imgref-label {
width: 100%;
margin-top: 8px;
font-size: 11px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
padding: 4px 8px;
flex-shrink: 0;
}
.imgref-label:focus {
border-color: var(--accent);
outline: none;
}
.imgref-label::placeholder {
color: var(--text-muted);
}
.imgref-replace-btn {
width: 100%;
height: 24px;
margin-top: 6px;
font-size: 10px;
background: transparent;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s;
}
.imgref-replace-btn:hover {
border-color: var(--accent);
color: var(--text-secondary);
}
/* ============================================
WIDGET TOOLBAR
============================================ */
.widget-toolbar {
position: fixed;
right: 16px;
top: 50%;
transform: translateY(-50%);
z-index: 100;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 6px;
background: var(--bg-board);
backdrop-filter: blur(20px);
border: 1px solid var(--border-accent);
border-radius: var(--radius);
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
}
.toolbar-left .widget-toolbar {
right: auto;
left: 16px;
}
.widget-toolbar-btn {
width: 36px; height: 36px;
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
display: flex; align-items: center; justify-content: center;
padding: 0;
}
.widget-toolbar-btn:hover {
background: var(--accent-dim);
border-color: var(--border-accent);
color: var(--accent);
}
/* ============================================
NOTEBOOK SIDEBAR
============================================ */
.notebook-overlay {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.5);
opacity: 0; pointer-events: none;
transition: opacity 0.25s;
}
.notebook-overlay.active {
opacity: 1; pointer-events: all;
}
.notebook-panel {
position: fixed; top: 0; right: -360px;
width: 340px; height: 100vh; z-index: 201;
background: rgba(8,8,16,0.96);
border-left: 1px solid var(--border);
backdrop-filter: blur(28px);
transition: right 0.3s ease;
display: flex; flex-direction: column;
overflow: hidden;
}
.notebook-overlay.active + .notebook-panel,
.notebook-panel.open {
right: 0;
}
.toolbar-left .notebook-panel {
right: auto; left: -360px;
border-left: none;
border-right: 1px solid var(--border);
}
.toolbar-left .notebook-overlay.active + .notebook-panel,
.toolbar-left .notebook-panel.open {
left: 0;
}
.notebook-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
}
.notebook-header-title {
font-family: var(--font-display); font-size: 14px; font-weight: 600;
letter-spacing: 1.5px; text-transform: uppercase;
color: var(--text-primary);
display: flex; align-items: center; gap: 8px;
}
.notebook-count {
font-size: 11px; color: var(--text-muted);
font-weight: 400; letter-spacing: 0;
}
.notebook-slots {
flex: 1; overflow-y: auto; padding: 12px;
display: flex; flex-direction: column; gap: 10px;
scrollbar-width: thin; scrollbar-color: var(--border) transparent;
}
/* Belegter Slot */
.notebook-slot {
background: rgba(255,255,255,0.03);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s;
}
.notebook-slot:hover {
border-color: var(--border-accent);
background: var(--accent-dim);
}
.notebook-slot-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 4px;
}
.notebook-slot-title {
font-family: var(--font-display); font-size: 12px; font-weight: 600;
letter-spacing: 1px; text-transform: uppercase;
color: var(--accent);
display: flex; align-items: center; gap: 5px;
}
.notebook-slot-type {
font-size: 12px;
}
.notebook-slot-preview {
font-family: var(--font-body); font-size: 11px;
color: var(--text-muted);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
margin-bottom: 6px;
}
.notebook-slot-actions {
display: flex; gap: 6px; justify-content: flex-end;
}
.notebook-slot-btn {
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-body); font-size: 10px;
padding: 3px 8px;
cursor: pointer;
transition: all 0.15s;
}
.notebook-slot-btn:hover {
border-color: var(--border-accent);
color: var(--accent);
}
.notebook-slot-btn.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
/* Leerer Slot */
.notebook-slot-empty {
border: 1px dashed var(--border);
border-radius: var(--radius);
padding: 14px 12px;
text-align: center;
cursor: pointer;
transition: all 0.15s;
color: var(--text-muted);
font-family: var(--font-body); font-size: 12px;
}
.notebook-slot-empty:hover {
border-color: var(--border-accent);
color: var(--text-secondary);
background: var(--accent-dim);
}
/* Typ-Auswahl im leeren Slot */
.notebook-type-chooser {
display: flex; gap: 8px; justify-content: center;
padding-top: 8px;
}
.notebook-type-btn {
background: rgba(255,255,255,0.04);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-body); font-size: 11px;
padding: 6px 12px;
cursor: pointer;
transition: all 0.15s;
display: flex; align-items: center; gap: 5px;
}
.notebook-type-btn:hover {
border-color: var(--border-accent);
color: var(--accent);
background: var(--accent-dim);
}
::-webkit-scrollbar { width: 4px; } ::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
@@ -1211,6 +1883,9 @@ body.show-desc .bm-desc { display: block; }
.theme-modal { max-width: 400px; } .theme-modal { max-width: 400px; }
.search-bar { max-width: 400px; } .search-bar { max-width: 400px; }
.widget-toolbar-btn { width: 32px; height: 32px; }
.notebook-panel { width: 320px; }
.calc-btn { height: 32px; font-size: 13px; }
} }
/* Smartphone (max 480px) */ /* Smartphone (max 480px) */
@@ -1242,7 +1917,13 @@ body.show-desc .bm-desc { display: block; }
.search-bar { max-width: 100%; } .search-bar { max-width: 100%; }
.search-input { font-size: 13px; padding: 8px 10px; } .search-input { font-size: 13px; padding: 8px 10px; }
.sticky-note { width: 200px; bottom: 12px; right: 12px; } .widget { min-width: 180px; }
.widget-toolbar { bottom: 12px; top: auto; right: 50%; transform: translateX(50%); flex-direction: row; }
.toolbar-left .widget-toolbar { left: 50%; right: auto; transform: translateX(-50%); }
.widget-toolbar-btn { width: 32px; height: 32px; }
.notebook-panel { width: 100%; right: -100%; }
.calc-btn { height: 30px; font-size: 12px; }
.toolbar-left .notebook-panel { left: -100%; }
.modal { width: calc(100vw - 32px); } .modal { width: calc(100vw - 32px); }
} }
+50 -2
View File
@@ -16,7 +16,11 @@ async function init() {
bindGlobalEvents(); bindGlobalEvents();
bindSettingsEvents(); bindSettingsEvents();
initSearch(); initSearch();
initStickyNote(); await migrateSticky();
await Notes.init();
await Calculator.init();
await Timer.init();
await ImageRef.init();
initDataButtons(); initDataButtons();
Store.checkQuota(); Store.checkQuota();
@@ -30,6 +34,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 +99,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.7.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
+729
View File
@@ -0,0 +1,729 @@
/* =============================================
HELLION NEWTAB — calculator.js
Taschenrechner Widget: Expression-Parsing,
History, Tastatureingabe
============================================= */
const Calculator = {
WIDGET_ID: 'widget_calculator',
STORAGE_KEY: 'widgetStates',
MAX_HISTORY: 10,
/** @type {Array<{expr: string, result: string}>} */
_history: [],
_currentExpr: '',
_lastResult: '',
_isOpen: false,
_displayExprEl: null,
_displayResultEl: null,
_keydownHandler: null,
// ---- STORAGE ----
/**
* Calculator-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator) {
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
}
},
/**
* Calculator-State in Storage speichern
* Bestehende Notes-Daten bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
const notesState = Array.isArray(data.notes) ? data.notes : [];
// Widget-Position aus WidgetManager holen
const widgetState = WidgetManager.getState(this.WIDGET_ID);
const calcData = {
x: widgetState ? widgetState.x : 400,
y: widgetState ? widgetState.y : 120,
width: widgetState ? widgetState.width : 280,
height: widgetState ? widgetState.height : 400,
open: this._isOpen,
history: this._history.slice(0, this.MAX_HISTORY)
};
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
},
// ---- WIDGET LIFECYCLE ----
/**
* Calculator oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
// Gespeicherte Position laden
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.calculator) ? data.calculator : {};
const widgetId = WidgetManager.create('calculator', {
id: this.WIDGET_ID,
title: 'Taschenrechner',
x: saved.x || 400,
y: saved.y || 120,
width: saved.width || 280,
height: saved.height || 400,
open: true
});
const body = WidgetManager.getBody(widgetId);
if (body) this.renderBody(body);
this._isOpen = true;
// Keyboard-Events binden
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Calculator toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
this._isOpen = false;
this._unbindKeyboard();
this._displayExprEl = null;
this._displayResultEl = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Calculator-Body rendern (in Widget-Body einfuegen)
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
// Display
const display = document.createElement('div');
display.className = 'calc-display';
const exprEl = document.createElement('div');
exprEl.className = 'calc-expression';
this._displayExprEl = exprEl;
const resultEl = document.createElement('div');
resultEl.className = 'calc-result';
resultEl.textContent = '0';
this._displayResultEl = resultEl;
display.append(exprEl, resultEl);
// Buttons
const buttonsEl = this._createButtons();
// History
const historyEl = this._createHistoryPanel();
bodyEl.append(display, buttonsEl, historyEl);
// Aktuellen State anzeigen
this._updateDisplay();
},
/**
* Button-Grid erstellen (4x5)
* @returns {HTMLElement}
*/
_createButtons() {
const grid = document.createElement('div');
grid.className = 'calc-buttons';
// Button-Layout: [label, value, cssClass]
const buttons = [
['C', 'clear', 'clear'],
['()', 'paren', 'operator'],
['%', '%', 'operator'],
['\u00F7', '/', 'operator'],
['7', '7', ''],
['8', '8', ''],
['9', '9', ''],
['\u00D7', '*', 'operator'],
['4', '4', ''],
['5', '5', ''],
['6', '6', ''],
['\u2212', '-', 'operator'],
['1', '1', ''],
['2', '2', ''],
['3', '3', ''],
['+', '+', 'operator'],
['0', '0', ''],
['.', '.', ''],
['\u232B', 'backspace', ''],
['=', '=', 'equals']
];
buttons.forEach(([label, value, cls]) => {
const btn = document.createElement('button');
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
btn.textContent = label;
btn.type = 'button';
btn.addEventListener('click', () => this._handleKey(value));
grid.appendChild(btn);
});
return grid;
},
/**
* History-Panel erstellen
* @returns {HTMLElement}
*/
_createHistoryPanel() {
const container = document.createElement('div');
container.className = 'calc-history';
container.id = 'calcHistoryPanel';
const title = document.createElement('div');
title.className = 'calc-history-title';
title.textContent = 'History';
container.appendChild(title);
this._renderHistoryItems(container);
return container;
},
/**
* History-Items rendern
* @param {HTMLElement} container
*/
_renderHistoryItems(container) {
// Alte Items entfernen (nur die .calc-history-item Elemente)
const oldItems = container.querySelectorAll('.calc-history-item');
oldItems.forEach(item => item.remove());
if (this._history.length === 0) return;
// Neueste zuerst
const reversed = [...this._history].reverse();
reversed.forEach(entry => {
const item = document.createElement('div');
item.className = 'calc-history-item';
const exprSpan = document.createElement('span');
exprSpan.textContent = entry.expr;
const resultSpan = document.createElement('span');
resultSpan.className = 'calc-h-result';
resultSpan.textContent = '= ' + entry.result;
item.append(exprSpan, resultSpan);
// Klick uebernimmt Ergebnis als neue Eingabe
item.addEventListener('click', () => {
this._currentExpr = entry.result;
this._lastResult = '';
this._updateDisplay();
});
container.appendChild(item);
});
},
// ---- INPUT HANDLING ----
/**
* Taste verarbeiten
* @param {string} key
*/
_handleKey(key) {
switch (key) {
case 'clear':
this._currentExpr = '';
this._lastResult = '';
break;
case 'backspace':
this._currentExpr = this._currentExpr.slice(0, -1);
break;
case '=':
this._calculate();
return;
case 'paren': {
// Smarte Klammern: oeffnende wenn noetig, sonst schliessende
const openCount = (this._currentExpr.match(/\(/g) || []).length;
const closeCount = (this._currentExpr.match(/\)/g) || []).length;
const lastChar = this._currentExpr.slice(-1);
if (openCount <= closeCount || /[+\-*/%(]$/.test(lastChar) || this._currentExpr === '') {
this._currentExpr += '(';
} else {
this._currentExpr += ')';
}
break;
}
case '%':
case '+':
case '-':
case '*':
case '/': {
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
if (this._lastResult && this._currentExpr === '') {
this._currentExpr = this._lastResult;
this._lastResult = '';
}
// Doppelte Operatoren verhindern (letzten ersetzen)
const last = this._currentExpr.slice(-1);
if (/[+\-*/%]/.test(last)) {
this._currentExpr = this._currentExpr.slice(0, -1) + key;
} else {
this._currentExpr += key;
}
break;
}
case '.': {
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
const parts = this._currentExpr.split(/[+\-*/%()]/);
const lastPart = parts[parts.length - 1];
if (lastPart && lastPart.includes('.')) break;
this._currentExpr += key;
break;
}
default:
// Ziffern 0-9
if (/^[0-9]$/.test(key)) {
// Wenn ein Ergebnis da ist und User eine Zahl tippt, neue Berechnung starten
if (this._lastResult && this._currentExpr === '') {
this._lastResult = '';
}
this._currentExpr += key;
}
break;
}
this._updateDisplay();
},
/**
* Berechnung ausfuehren
*/
async _calculate() {
if (!this._currentExpr) return;
const result = this._evaluate(this._currentExpr);
if (result === null) {
this._lastResult = 'Fehler';
this._updateDisplay();
return;
}
const resultStr = this._formatResult(result);
this._addHistory(this._currentExpr, resultStr);
this._lastResult = resultStr;
// Display aktualisieren
if (this._displayExprEl) {
this._displayExprEl.textContent = this._formatExpression(this._currentExpr) + ' =';
}
if (this._displayResultEl) {
this._displayResultEl.textContent = resultStr;
}
this._currentExpr = '';
// History-Panel aktualisieren
const historyPanel = document.getElementById('calcHistoryPanel');
if (historyPanel) this._renderHistoryItems(historyPanel);
await this.save();
},
// ---- EXPRESSION PARSER (Shunting-Yard, KEIN eval!) ----
/**
* Expression sicher auswerten
* @param {string} expr
* @returns {number|null}
*/
_evaluate(expr) {
try {
// Nur erlaubte Zeichen
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
if (!sanitized) return null;
const tokens = this._tokenize(sanitized);
if (!tokens) return null;
return this._parseExpression(tokens);
} catch {
return null;
}
},
/**
* Expression in Tokens aufteilen
* @param {string} expr
* @returns {Array|null}
*/
_tokenize(expr) {
const tokens = [];
let i = 0;
while (i < expr.length) {
const ch = expr[i];
// Zahl (inkl. Dezimal)
if (/[0-9.]/.test(ch)) {
let num = '';
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
// Operator
if (/[+\-*/%]/.test(ch)) {
// Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
if (ch === '-') {
const prev = tokens[tokens.length - 1];
if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
// Negatives Vorzeichen → als Teil der naechsten Zahl lesen
let num = '-';
i++;
while (i < expr.length && /[0-9.]/.test(expr[i])) {
num += expr[i];
i++;
}
if (num === '-') return null;
const parsed = parseFloat(num);
if (isNaN(parsed)) return null;
tokens.push({ type: 'number', value: parsed });
continue;
}
}
tokens.push({ type: 'op', value: ch });
i++;
continue;
}
// Klammern
if (ch === '(' || ch === ')') {
tokens.push({ type: 'paren', value: ch });
i++;
continue;
}
// Unbekanntes Zeichen
return null;
}
return tokens;
},
/**
* Rekursiver Descent Parser mit Operator-Precedence
* @param {Array} tokens
* @returns {number|null}
*/
_parseExpression(tokens) {
let pos = 0;
function peek() { return tokens[pos]; }
function consume() { return tokens[pos++]; }
// Expression: Term (('+' | '-') Term)*
function parseExpr() {
let left = parseTerm();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
consume();
const right = parseTerm();
if (right === null) return null;
left = t.value === '+' ? left + right : left - right;
}
return left;
}
// Term: Factor (('*' | '/' | '%') Factor)*
function parseTerm() {
let left = parseFactor();
if (left === null) return null;
while (pos < tokens.length) {
const t = peek();
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
consume();
const right = parseFactor();
if (right === null) return null;
if (t.value === '*') {
left = left * right;
} else if (t.value === '/') {
if (right === 0) return null;
left = left / right;
} else {
left = left % right;
}
}
return left;
}
// Factor: Number | '(' Expression ')'
function parseFactor() {
const t = peek();
if (!t) return null;
if (t.type === 'number') {
consume();
return t.value;
}
if (t.type === 'paren' && t.value === '(') {
consume();
const val = parseExpr();
if (val === null) return null;
const closing = peek();
if (closing && closing.type === 'paren' && closing.value === ')') {
consume();
}
return val;
}
return null;
}
const result = parseExpr();
// Alle Tokens muessen verbraucht sein
if (pos < tokens.length) return null;
if (result === null || !isFinite(result)) return null;
return result;
},
// ---- FORMATTING ----
/**
* Ergebnis formatieren (maximal 10 Dezimalstellen, trailing Nullen entfernen)
* @param {number} num
* @returns {string}
*/
_formatResult(num) {
if (Number.isInteger(num)) return num.toString();
// Maximal 10 Dezimalstellen, trailing Nullen weg
const str = num.toFixed(10).replace(/\.?0+$/, '');
return str;
},
/**
* Expression fuer Anzeige formatieren (× statt *, ÷ statt /)
* @param {string} expr
* @returns {string}
*/
_formatExpression(expr) {
return expr
.replace(/\*/g, '\u00D7')
.replace(/\//g, '\u00F7');
},
// ---- DISPLAY ----
/**
* Display aktualisieren
*/
_updateDisplay() {
if (this._displayExprEl) {
if (this._lastResult) {
// Ergebnis-Modus: Expression oben, Ergebnis gross
// (wird von _calculate() direkt gesetzt)
} else {
this._displayExprEl.textContent = '';
}
}
if (this._displayResultEl) {
if (this._lastResult && this._currentExpr === '') {
this._displayResultEl.textContent = this._lastResult;
} else {
this._displayResultEl.textContent = this._formatExpression(this._currentExpr) || '0';
}
}
},
// ---- HISTORY ----
/**
* History-Eintrag hinzufuegen
* @param {string} expr
* @param {string} result
*/
_addHistory(expr, result) {
this._history.push({
expr: this._formatExpression(expr),
result: result
});
// Limit einhalten
if (this._history.length > this.MAX_HISTORY) {
this._history = this._history.slice(-this.MAX_HISTORY);
}
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nur reagieren wenn Calculator-Widget fokussiert ist
// (d.h. nicht wenn User in Textarea/Input tippt)
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
if (e.target.contentEditable === 'true') return;
const key = e.key;
let handled = false;
if (/^[0-9]$/.test(key)) {
this._handleKey(key);
handled = true;
} else if (key === '+' || key === '-' || key === '*' || key === '/') {
this._handleKey(key);
handled = true;
} else if (key === '.') {
this._handleKey('.');
handled = true;
} else if (key === '%') {
this._handleKey('%');
handled = true;
} else if (key === '(' || key === ')') {
this._handleKey('paren');
handled = true;
} else if (key === 'Enter' || key === '=') {
this._handleKey('=');
handled = true;
} else if (key === 'Backspace') {
this._handleKey('backspace');
handled = true;
} else if (key === 'Escape' || key === 'c' || key === 'C') {
this._handleKey('clear');
handled = true;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
// Widget fokussierbar machen
widgetEl.tabIndex = 0;
widgetEl.focus();
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Calculator initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.calculator && data.calculator.open) {
await this.open();
}
// Close-Event abfangen: WidgetManager.close() ueberschreiben
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
WidgetManager.close = function(id) {
origClose(id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
WidgetManager.minimize = async function(id) {
await origMinimize(id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Open-Event abfangen
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
WidgetManager.openWidget = async function(id) {
await origOpen(id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
// Body neu rendern (war durch minimize entfernt)
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
await self.save();
}
};
}
};
+69 -4
View File
@@ -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.7.0',
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) {
+500
View File
@@ -0,0 +1,500 @@
/* =============================================
HELLION NEWTAB — image-ref.js
Bild-Referenz Widget: Session-only Bildanzeige
mit Canvas API WebP-Konvertierung
============================================= */
const ImageRef = {
MAX_IMAGES: 3,
STORAGE_KEY: 'widgetStates',
SESSION_KEY: 'imageRefData',
/** @type {Array<{id: string, label: string, x: number, y: number, width: number, height: number, open: boolean}>} */
_images: [],
_saveTimer: null,
// ---- STORAGE (persistent: Position/Meta) ----
/**
* Widget-Meta aus persistentem Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.imageRef && Array.isArray(data.imageRef.images)) {
this._images = data.imageRef.images;
}
},
/**
* Widget-Meta persistent speichern
* Bestehende Notes, Calculator, Timer bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
// Positionen aus WidgetManager aktualisieren
const updated = this._images.map(img => {
const ws = WidgetManager.getState(img.id);
if (ws) {
img.x = ws.x;
img.y = ws.y;
img.width = ws.width;
img.height = ws.height;
img.open = ws.open;
}
return img;
});
data.imageRef = { images: updated };
await Store.set(this.STORAGE_KEY, data);
},
/**
* Debounced Save
*/
_debouncedSave() {
clearTimeout(this._saveTimer);
this._saveTimer = setTimeout(() => this.save(), 500);
},
// ---- SESSION STORAGE (Bilddaten) ----
/**
* Bilddaten in sessionStorage speichern
*/
_saveSession() {
try {
const sessionData = {};
this._images.forEach(img => {
const dataUrl = this._getSessionImage(img.id);
if (dataUrl) sessionData[img.id] = dataUrl;
});
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
}
},
/**
* Bilddaten aus sessionStorage laden
* @returns {Object} - { id: dataUrl, ... }
*/
_loadSessionAll() {
try {
const raw = sessionStorage.getItem(this.SESSION_KEY);
if (!raw) return {};
return JSON.parse(raw);
} catch (e) {
console.warn('ImageRef: sessionStorage Read fehlgeschlagen', e);
return {};
}
},
/**
* Einzelnes Bild aus sessionStorage lesen
* @param {string} id
* @returns {string|null}
*/
_getSessionImage(id) {
const all = this._loadSessionAll();
return all[id] || null;
},
/**
* Einzelnes Bild in sessionStorage setzen
* @param {string} id
* @param {string} dataUrl
*/
_setSessionImage(id, dataUrl) {
try {
const all = this._loadSessionAll();
all[id] = dataUrl;
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e);
HellionDialog.alert(
'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.',
{ type: 'danger', title: 'Speicherfehler' }
);
}
},
/**
* Einzelnes Bild aus sessionStorage entfernen
* @param {string} id
*/
_removeSessionImage(id) {
try {
const all = this._loadSessionAll();
delete all[id];
sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all));
} catch (e) {
console.warn('ImageRef: sessionStorage Remove fehlgeschlagen', e);
}
},
// ---- WIDGET LIFECYCLE ----
/**
* Neues Bild-Widget erstellen (oeffnet File-Dialog)
*/
async create() {
if (!settings.imageRefEnabled) return;
if (this._images.length >= this.MAX_IMAGES) {
await HellionDialog.alert(
'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.',
{ type: 'warning', title: 'Limit erreicht' }
);
return;
}
// Freie ID finden
const usedIds = new Set(this._images.map(i => i.id));
let slotId = null;
for (let i = 0; i < this.MAX_IMAGES; i++) {
const candidate = 'image_' + i;
if (!usedIds.has(candidate)) {
slotId = candidate;
break;
}
}
if (!slotId) return;
// File-Dialog
const file = await this._pickFile();
if (!file) return;
// Bild verarbeiten
let dataUrl;
try {
dataUrl = await this._processFile(file);
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
return;
}
// In sessionStorage speichern
this._setSessionImage(slotId, dataUrl);
// Meta erstellen
const imageData = {
id: slotId,
label: '',
x: 200 + (this._images.length * 40),
y: 120 + (this._images.length * 30),
width: 320,
height: 280,
open: true
};
this._images.push(imageData);
// Widget erstellen
this._createWidget(imageData, dataUrl);
await this.save();
},
/**
* Widget im DOM erstellen
* @param {Object} imageData
* @param {string|null} dataUrl
*/
_createWidget(imageData, dataUrl) {
WidgetManager.create('image', {
id: imageData.id,
title: imageData.label || 'Bild-Referenz',
x: imageData.x,
y: imageData.y,
width: imageData.width,
height: imageData.height,
open: imageData.open !== false
});
const body = WidgetManager.getBody(imageData.id);
if (body) this.renderBody(imageData, body, dataUrl);
},
/**
* Widget geschlossen — Daten aufraeumen
* @param {string} id
*/
async onClose(id) {
this._removeSessionImage(id);
this._images = this._images.filter(img => img.id !== id);
await this.save();
},
// ---- UI RENDERING ----
/**
* Widget-Body rendern
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @param {string|null} dataUrl
*/
renderBody(imageData, bodyEl, dataUrl) {
bodyEl.textContent = '';
const container = document.createElement('div');
container.className = 'imgref-container';
if (dataUrl) {
// Bild anzeigen
const wrapper = document.createElement('div');
wrapper.className = 'imgref-img-wrapper';
const img = document.createElement('img');
img.className = 'imgref-img';
img.src = dataUrl;
img.alt = imageData.label || 'Bild-Referenz';
wrapper.appendChild(img);
// Bild ersetzen Button
const replaceBtn = document.createElement('button');
replaceBtn.className = 'imgref-replace-btn';
replaceBtn.type = 'button';
replaceBtn.textContent = 'Bild ersetzen';
replaceBtn.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const newDataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, newDataUrl);
this.renderBody(imageData, bodyEl, newDataUrl);
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
container.append(wrapper, replaceBtn);
} else {
// Drop-Zone (kein Bild vorhanden)
const dropzone = this._createDropzone(imageData, bodyEl);
container.appendChild(dropzone);
}
// Label-Input
const label = document.createElement('input');
label.className = 'imgref-label';
label.type = 'text';
label.placeholder = 'Beschriftung (optional)';
label.maxLength = 100;
label.value = imageData.label || '';
label.addEventListener('input', () => {
const text = label.value.trim().slice(0, 100);
imageData.label = text;
// Widget-Titel aktualisieren
const entry = WidgetManager._widgets.get(imageData.id);
if (entry) {
const titleEl = entry.el.querySelector('.widget-title-text');
if (titleEl) titleEl.textContent = text || 'Bild-Referenz';
entry.state.title = text || 'Bild-Referenz';
}
this._debouncedSave();
});
container.appendChild(label);
bodyEl.appendChild(container);
},
/**
* Drop-Zone erstellen (fuer leere Widgets / neue Bilder)
* @param {Object} imageData
* @param {HTMLElement} bodyEl
* @returns {HTMLElement}
*/
_createDropzone(imageData, bodyEl) {
const dropzone = document.createElement('div');
dropzone.className = 'imgref-dropzone';
const icon = document.createElement('div');
icon.className = 'imgref-dropzone-icon';
icon.textContent = '\uD83D\uDDBC\uFE0F';
const text = document.createElement('span');
text.textContent = 'Klicken oder Bild hierher ziehen';
dropzone.append(icon, text);
// Klick -> File-Dialog
dropzone.addEventListener('click', async () => {
const file = await this._pickFile();
if (!file) return;
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
// Drag & Drop
dropzone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('dragover');
const file = e.dataTransfer.files[0];
if (!file || !file.type.startsWith('image/')) {
await HellionDialog.alert(
'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).',
{ type: 'warning', title: 'Kein Bild' }
);
return;
}
try {
const dataUrl = await this._processFile(file);
this._setSessionImage(imageData.id, dataUrl);
this.renderBody(imageData, bodyEl, dataUrl);
await this.save();
} catch (err) {
await HellionDialog.alert(
'Bild konnte nicht geladen werden: ' + err.message,
{ type: 'danger', title: 'Bildfehler' }
);
}
});
return dropzone;
},
// ---- FILE HANDLING ----
/**
* File-Dialog oeffnen
* @returns {Promise<File|null>}
*/
_pickFile() {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', () => {
resolve(input.files[0] || null);
});
// Cancel erkennen
input.addEventListener('cancel', () => resolve(null));
input.click();
});
},
/**
* Bild per Canvas API zu WebP konvertieren
* @param {File} file
* @returns {Promise<string>} WebP DataURL
*/
_processFile(file) {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const webpUrl = canvas.toDataURL('image/webp', 0.85);
URL.revokeObjectURL(objectUrl);
resolve(webpUrl);
} catch (err) {
URL.revokeObjectURL(objectUrl);
reject(err);
}
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Bild konnte nicht geladen werden'));
};
img.src = objectUrl;
});
},
// ---- INIT ----
/**
* ImageRef initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Widgets wiederherstellen (nur wenn Feature aktiviert)
if (settings.imageRefEnabled && this._images.length > 0) {
const sessionData = this._loadSessionAll();
this._images.forEach(imageData => {
if (imageData.open !== false) {
const dataUrl = sessionData[imageData.id] || null;
this._createWidget(imageData, dataUrl);
}
});
}
// Close-Event abfangen
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
// Pruefen ob es ein Image-Widget ist
const isImage = self._images.some(img => img.id === id);
if (isImage) {
self.onClose(id);
}
};
// Minimize-Event abfangen
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
const isImage = self._images.some(img => img.id === id);
if (isImage) {
await self.save();
}
};
// Open-Event abfangen
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
const imgData = self._images.find(img => img.id === id);
if (imgData) {
const body = WidgetManager.getBody(id);
if (body && body.children.length === 0) {
const dataUrl = self._getSessionImage(id);
self.renderBody(imgData, body, dataUrl);
}
await self.save();
}
};
}
};
+573
View File
@@ -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());
}
}
};
+2 -1
View File
@@ -33,7 +33,8 @@ const Onboarding = {
title: 'Weitere Features', title: 'Weitere Features',
features: [ features: [
'Suchleiste mit Google, DuckDuckGo oder Bing', 'Suchleiste mit Google, DuckDuckGo oder Bing',
'Sticky Notes f\u00FCr schnelle Notizen', 'Widget-Toolbar rechts \u2014 Notes und Checklisten erstellen',
'Notebook-Sidebar \u00FCber den \u201ENote\u201C Button oder die Toolbar',
'Funktioniert komplett offline \u2014 alles lokal gespeichert' 'Funktioniert komplett offline \u2014 alles lokal gespeichert'
] ]
}, },
+31 -2
View File
@@ -25,7 +25,7 @@ function closeThemeModal() {
// ---- ACCORDION ---- // ---- ACCORDION ----
function initAccordion() { function initAccordion() {
const defaultOpen = new Set(['appearance', 'behavior', 'data', 'help']); const defaultOpen = new Set(['appearance', 'behavior', 'widgets', 'data', 'help']);
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();
+3 -1
View File
@@ -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() {
+760
View File
@@ -0,0 +1,760 @@
/* =============================================
HELLION NEWTAB — timer.js
Timer / Countdown Widget: Presets, Alarm,
Tab-Titel-Blink
============================================= */
const Timer = {
WIDGET_ID: 'widget_timer',
STORAGE_KEY: 'widgetStates',
MAX_PRESETS: 5,
/** @type {Array<{name: string, seconds: number}>} */
_presets: [],
_isOpen: false,
_seconds: 0,
_remaining: 0,
_intervalId: null,
_running: false,
_finished: false,
_blinkIntervalId: null,
_originalTitle: '',
_keydownHandler: null,
_muted: false,
// UI-Referenzen
_timeEl: null,
_muteBtn: null,
_inputEl: null,
_inputRow: null,
_btnStart: null,
_btnPause: null,
_btnReset: null,
// ---- STORAGE ----
/**
* Timer-State aus Storage laden
*/
async load() {
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer) {
this._presets = Array.isArray(data.timer.presets) ? data.timer.presets : [];
if (typeof data.timer.muted === 'boolean') this._muted = data.timer.muted;
}
},
/**
* Timer-State in Storage speichern
* Bestehende Notes + Calculator bleiben erhalten
*/
async save() {
const data = await Store.get(this.STORAGE_KEY) || {};
if (data.notes === undefined) data.notes = [];
const widgetState = WidgetManager.getState(this.WIDGET_ID);
data.timer = {
x: widgetState ? widgetState.x : 600,
y: widgetState ? widgetState.y : 80,
width: widgetState ? widgetState.width : 260,
height: widgetState ? widgetState.height : 360,
open: this._isOpen,
presets: this._presets.slice(0, this.MAX_PRESETS),
muted: this._muted
};
await Store.set(this.STORAGE_KEY, data);
},
// ---- WIDGET LIFECYCLE ----
/**
* Timer-Widget oeffnen oder in Vordergrund bringen
*/
async open() {
if (this._isOpen) {
WidgetManager.bringToFront(this.WIDGET_ID);
return;
}
const data = await Store.get(this.STORAGE_KEY);
const saved = (data && data.timer) ? data.timer : {};
WidgetManager.create('timer', {
id: this.WIDGET_ID,
title: 'Timer',
x: saved.x || 600,
y: saved.y || 80,
width: saved.width || 260,
height: saved.height || 360,
open: true
});
const body = WidgetManager.getBody(this.WIDGET_ID);
if (body) this.renderBody(body);
this._isOpen = true;
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) this._bindKeyboard(entry.el);
await this.save();
},
/**
* Timer toggle: oeffnen oder minimieren
*/
async toggle() {
if (this._isOpen) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry && entry.state.open) {
await WidgetManager.minimize(this.WIDGET_ID);
this._isOpen = false;
await this.save();
} else if (entry) {
await WidgetManager.openWidget(this.WIDGET_ID);
this._isOpen = true;
await this.save();
}
} else {
await this.open();
}
},
/**
* Wird aufgerufen wenn Widget geschlossen wird
*/
async onClose() {
this._isOpen = false;
this._unbindKeyboard();
this._stopCountdown();
this._stopAlarm();
this._timeEl = null;
this._inputEl = null;
this._inputRow = null;
this._btnStart = null;
this._btnPause = null;
this._btnReset = null;
this._muteBtn = null;
await this.save();
},
// ---- UI RENDERING ----
/**
* Timer-Body rendern
* @param {HTMLElement} bodyEl
*/
renderBody(bodyEl) {
bodyEl.textContent = '';
bodyEl.style.padding = '8px';
bodyEl.style.display = 'flex';
bodyEl.style.flexDirection = 'column';
// Display
const display = document.createElement('div');
display.className = 'timer-display';
const timeEl = document.createElement('div');
timeEl.className = 'timer-time';
timeEl.textContent = '00:00';
this._timeEl = timeEl;
display.appendChild(timeEl);
// Input
const inputRow = document.createElement('div');
inputRow.className = 'timer-input-row';
this._inputRow = inputRow;
const input = document.createElement('input');
input.className = 'timer-input';
input.type = 'text';
input.placeholder = 'mm:ss';
input.maxLength = 8;
this._inputEl = input;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this._applyInput();
this._start();
}
});
inputRow.appendChild(input);
// Controls
const controls = document.createElement('div');
controls.className = 'timer-controls';
const btnStart = document.createElement('button');
btnStart.className = 'timer-ctrl-btn primary';
btnStart.type = 'button';
btnStart.textContent = 'Start';
btnStart.addEventListener('click', () => {
if (!this._running && this._remaining === 0) {
this._applyInput();
}
this._start();
});
this._btnStart = btnStart;
const btnPause = document.createElement('button');
btnPause.className = 'timer-ctrl-btn';
btnPause.type = 'button';
btnPause.textContent = 'Pause';
btnPause.disabled = true;
btnPause.addEventListener('click', () => this._pause());
this._btnPause = btnPause;
const btnReset = document.createElement('button');
btnReset.className = 'timer-ctrl-btn danger';
btnReset.type = 'button';
btnReset.textContent = 'Reset';
btnReset.addEventListener('click', () => this._reset());
this._btnReset = btnReset;
controls.append(btnStart, btnPause, btnReset);
// Mute Toggle (in Controls-Zeile)
const muteBtn = document.createElement('button');
muteBtn.className = 'timer-mute-btn';
muteBtn.type = 'button';
this._muteBtn = muteBtn;
this._updateMuteBtn();
muteBtn.addEventListener('click', async () => {
this._muted = !this._muted;
this._updateMuteBtn();
await this.save();
});
controls.appendChild(muteBtn);
// Presets
const presetsEl = this._createPresetsPanel();
bodyEl.append(display, inputRow, controls, presetsEl);
// State wiederherstellen
this._updateDisplay();
this._updateControls();
},
/**
* Presets-Panel erstellen
* @returns {HTMLElement}
*/
_createPresetsPanel() {
const container = document.createElement('div');
container.className = 'timer-presets';
container.id = 'timerPresetsPanel';
const header = document.createElement('div');
header.className = 'timer-presets-header';
const title = document.createElement('span');
title.className = 'timer-presets-title';
title.textContent = 'Presets';
const addBtn = document.createElement('button');
addBtn.className = 'timer-preset-add';
addBtn.type = 'button';
addBtn.textContent = '+';
addBtn.title = 'Preset speichern';
addBtn.addEventListener('click', () => this._showAddPreset(container));
header.append(title, addBtn);
container.appendChild(header);
this._renderPresetItems(container);
return container;
},
/**
* Preset-Items rendern
* @param {HTMLElement} container
*/
_renderPresetItems(container) {
// Alte Items entfernen
const oldItems = container.querySelectorAll('.timer-preset-item, .timer-add-row');
oldItems.forEach(item => item.remove());
this._presets.forEach((preset, idx) => {
const item = document.createElement('div');
item.className = 'timer-preset-item';
const name = document.createElement('span');
name.className = 'timer-preset-name';
name.textContent = preset.name;
const time = document.createElement('span');
time.className = 'timer-preset-time';
time.textContent = this._formatTime(preset.seconds);
const del = document.createElement('button');
del.className = 'timer-preset-del';
del.type = 'button';
del.textContent = '\u2715';
del.addEventListener('click', async (e) => {
e.stopPropagation();
await this._deletePreset(idx);
this._renderPresetItems(container);
});
item.append(name, time, del);
// Klick laedt Preset
item.addEventListener('click', () => {
this._loadPreset(preset);
});
container.appendChild(item);
});
},
/**
* Add-Preset UI anzeigen
* @param {HTMLElement} container
*/
_showAddPreset(container) {
// Nur einmal anzeigen
if (container.querySelector('.timer-add-row')) return;
if (this._presets.length >= this.MAX_PRESETS) {
HellionDialog.alert(
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
{ type: 'warning', title: 'Limit erreicht' }
);
return;
}
// Aktuelle Zeit als Vorlage
const currentSeconds = this._remaining > 0 ? this._seconds : 0;
if (currentSeconds === 0 && this._inputEl) {
const parsed = this._parseTimeInput(this._inputEl.value);
if (parsed === 0) {
HellionDialog.alert(
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
{ type: 'info', title: 'Keine Zeit' }
);
return;
}
}
const row = document.createElement('div');
row.className = 'timer-add-row';
const nameInput = document.createElement('input');
nameInput.className = 'timer-add-input';
nameInput.type = 'text';
nameInput.placeholder = 'Name...';
nameInput.maxLength = 20;
const confirmBtn = document.createElement('button');
confirmBtn.className = 'timer-add-confirm';
confirmBtn.type = 'button';
confirmBtn.textContent = 'OK';
const doAdd = async () => {
const name = nameInput.value.trim();
if (!name) return;
let secs = this._seconds;
if (secs === 0 && this._inputEl) {
secs = this._parseTimeInput(this._inputEl.value);
}
if (secs === 0) return;
await this._addPreset(name, secs);
this._renderPresetItems(container);
};
confirmBtn.addEventListener('click', doAdd);
nameInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doAdd();
if (e.key === 'Escape') row.remove();
});
row.append(nameInput, confirmBtn);
container.appendChild(row);
nameInput.focus();
},
// ---- TIMER LOGIC ----
/**
* Input-Feld auslesen und als Sekunden setzen
*/
_applyInput() {
if (!this._inputEl) return;
const secs = this._parseTimeInput(this._inputEl.value);
if (secs > 0) {
this._seconds = secs;
this._remaining = secs;
}
},
/**
* Timer starten
*/
_start() {
if (this._running) return;
if (this._remaining <= 0) return;
// Falls gerade Alarm laeuft, stoppen
if (this._finished) {
this._stopAlarm();
this._finished = false;
}
this._running = true;
this._updateControls();
// Input verstecken
if (this._inputRow) this._inputRow.style.display = 'none';
this._intervalId = setInterval(() => this._tick(), 1000);
},
/**
* Timer pausieren
*/
_pause() {
if (!this._running) return;
this._running = false;
this._stopCountdown();
this._updateControls();
},
/**
* Timer zuruecksetzen
*/
_reset() {
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._remaining = 0;
this._seconds = 0;
// Input wieder anzeigen
if (this._inputRow) this._inputRow.style.display = 'flex';
if (this._inputEl) this._inputEl.value = '';
this._updateDisplay();
this._updateControls();
},
/**
* Jede Sekunde: remaining verringern, Display aktualisieren
*/
_tick() {
this._remaining--;
if (this._remaining <= 0) {
this._remaining = 0;
this._stopCountdown();
this._running = false;
this._finished = true;
this._onFinish();
}
this._updateDisplay();
this._updateControls();
},
/**
* Interval stoppen
*/
_stopCountdown() {
if (this._intervalId) {
clearInterval(this._intervalId);
this._intervalId = null;
}
},
/**
* Timer abgelaufen — Alarm + Tab-Blink
*/
_onFinish() {
if (!this._muted) this._playAlarm();
this._startTitleBlink();
},
/**
* Akustisches Signal (Browser Audio API, kein externer Request)
*/
_playAlarm() {
try {
const ctx = new AudioContext();
[0, 0.3, 0.6].forEach(delay => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 800;
gain.gain.value = 0.07;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.2);
});
} catch (e) {
console.warn('Timer: Audio nicht verfuegbar', e);
}
},
/**
* Tab-Titel blinken lassen
*/
_startTitleBlink() {
this._originalTitle = document.title;
this._blinkIntervalId = setInterval(() => {
document.title = document.title === '[!] Timer abgelaufen'
? this._originalTitle
: '[!] Timer abgelaufen';
}, 1000);
},
/**
* Tab-Titel Blink und Alarm stoppen
*/
_stopAlarm() {
if (this._blinkIntervalId) {
clearInterval(this._blinkIntervalId);
this._blinkIntervalId = null;
document.title = this._originalTitle || 'Hellion Dashboard';
}
this._finished = false;
this._updateDisplay();
this._updateControls();
},
/**
* Mute-Button Text/Titel aktualisieren
*/
_updateMuteBtn() {
if (!this._muteBtn) return;
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
this._muteBtn.classList.toggle('muted', this._muted);
},
// ---- DISPLAY ----
/**
* Zeitanzeige aktualisieren
*/
_updateDisplay() {
if (!this._timeEl) return;
this._timeEl.textContent = this._formatTime(this._remaining);
this._timeEl.classList.toggle('finished', this._finished);
},
/**
* Button-States aktualisieren
*/
_updateControls() {
if (this._btnStart) {
this._btnStart.disabled = this._running;
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
}
if (this._btnPause) {
this._btnPause.disabled = !this._running;
}
},
// ---- PRESETS ----
/**
* Preset hinzufuegen
* @param {string} name
* @param {number} seconds
*/
async _addPreset(name, seconds) {
if (this._presets.length >= this.MAX_PRESETS) return;
this._presets.push({ name, seconds });
await this.save();
},
/**
* Preset loeschen
* @param {number} index
*/
async _deletePreset(index) {
this._presets.splice(index, 1);
await this.save();
},
/**
* Preset laden (Zeit setzen)
* @param {Object} preset - { name, seconds }
*/
_loadPreset(preset) {
// Falls laufend, erst stoppen
this._stopCountdown();
this._stopAlarm();
this._running = false;
this._finished = false;
this._seconds = preset.seconds;
this._remaining = preset.seconds;
if (this._inputRow) this._inputRow.style.display = 'none';
this._updateDisplay();
this._updateControls();
},
// ---- FORMATTING ----
/**
* Sekunden in Zeitformat umwandeln
* @param {number} totalSeconds
* @returns {string} "05:30" oder "1:05:30"
*/
_formatTime(totalSeconds) {
const h = Math.floor(totalSeconds / 3600);
const m = Math.floor((totalSeconds % 3600) / 60);
const s = totalSeconds % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
if (h > 0) {
return h + ':' + mm + ':' + ss;
}
return mm + ':' + ss;
},
/**
* Zeit-String in Sekunden parsen
* Akzeptiert: "5:30", "05:30", "1:05:30", "90" (Sekunden)
* @param {string} str
* @returns {number}
*/
_parseTimeInput(str) {
const trimmed = (str || '').trim();
if (!trimmed) return 0;
const parts = trimmed.split(':');
if (parts.length === 1) {
// Nur Zahl = Sekunden
const secs = parseInt(parts[0], 10);
return isNaN(secs) ? 0 : Math.max(0, secs);
}
if (parts.length === 2) {
// mm:ss
const m = parseInt(parts[0], 10);
const s = parseInt(parts[1], 10);
if (isNaN(m) || isNaN(s)) return 0;
return Math.max(0, m * 60 + s);
}
if (parts.length === 3) {
// hh:mm:ss
const h = parseInt(parts[0], 10);
const m = parseInt(parts[1], 10);
const s = parseInt(parts[2], 10);
if (isNaN(h) || isNaN(m) || isNaN(s)) return 0;
return Math.max(0, h * 3600 + m * 60 + s);
}
return 0;
},
// ---- KEYBOARD ----
/**
* Tastatur-Events binden
* @param {HTMLElement} widgetEl
*/
_bindKeyboard(widgetEl) {
this._unbindKeyboard();
this._keydownHandler = (e) => {
// Nicht reagieren wenn User in Input tippt
if (e.target.tagName === 'INPUT') return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
if (this._running) {
this._pause();
} else if (this._remaining > 0) {
this._start();
}
} else if (e.key === 'Escape' || e.key === 'r' || e.key === 'R') {
e.preventDefault();
this._reset();
}
};
widgetEl.addEventListener('keydown', this._keydownHandler);
widgetEl.tabIndex = 0;
},
/**
* Keyboard-Events entfernen
*/
_unbindKeyboard() {
if (this._keydownHandler) {
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
if (entry) {
entry.el.removeEventListener('keydown', this._keydownHandler);
}
this._keydownHandler = null;
}
},
// ---- INIT ----
/**
* Timer initialisieren (aus app.js aufgerufen)
*/
async init() {
await this.load();
// Wenn Timer beim letzten Mal offen war, wiederherstellen
const data = await Store.get(this.STORAGE_KEY);
if (data && data.timer && data.timer.open) {
await this.open();
}
// Close-Event abfangen
const origClose = WidgetManager.close.bind(WidgetManager);
const self = this;
const prevClose = WidgetManager.close;
WidgetManager.close = function(id) {
prevClose.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self.onClose();
}
};
// Minimize-Event abfangen
const prevMinimize = WidgetManager.minimize;
WidgetManager.minimize = async function(id) {
await prevMinimize.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = false;
await self.save();
}
};
// Open-Event abfangen
const prevOpen = WidgetManager.openWidget;
WidgetManager.openWidget = async function(id) {
await prevOpen.call(WidgetManager, id);
if (id === self.WIDGET_ID) {
self._isOpen = true;
const body = WidgetManager.getBody(self.WIDGET_ID);
if (body && body.children.length === 0) {
self.renderBody(body);
}
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
if (entry) self._bindKeyboard(entry.el);
await self.save();
}
};
}
};
+365
View File
@@ -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());
}
};