Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b55bb7ac34 | |||
| 37e45a2041 |
@@ -67,6 +67,12 @@
|
|||||||
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
<button class="widget-toolbar-btn" data-action="new-checklist" title="Checkliste erstellen">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="widget-toolbar-btn" data-action="calculator" title="Taschenrechner">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="2" width="16" height="20" rx="2"/><line x1="8" y1="6" x2="16" y2="6"/><circle cx="8" cy="10" r="0.5"/><circle cx="12" cy="10" r="0.5"/><circle cx="16" cy="10" r="0.5"/><circle cx="8" cy="14" r="0.5"/><circle cx="12" cy="14" r="0.5"/><circle cx="16" cy="14" r="0.5"/><circle cx="8" cy="18" r="0.5"/><circle cx="12" cy="18" r="0.5"/><circle cx="16" cy="18" r="0.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="widget-toolbar-btn" data-action="timer" title="Timer">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="13" r="8"/><path d="M12 9v4l2 2"/><path d="M5 3l2 2"/><path d="M19 3l-2 2"/><line x1="12" y1="1" x2="12" y2="3"/></svg>
|
||||||
|
</button>
|
||||||
<button class="widget-toolbar-btn" data-action="notebook" title="Alle Notes">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -475,6 +481,8 @@
|
|||||||
<script src="src/js/search.js"></script>
|
<script src="src/js/search.js"></script>
|
||||||
<script src="src/js/widgets.js"></script>
|
<script src="src/js/widgets.js"></script>
|
||||||
<script src="src/js/notes.js"></script>
|
<script src="src/js/notes.js"></script>
|
||||||
|
<script src="src/js/calculator.js"></script>
|
||||||
|
<script src="src/js/timer.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>
|
||||||
|
|||||||
+312
-2
@@ -908,7 +908,7 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
============================================ */
|
============================================ */
|
||||||
.widget {
|
.widget {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 51;
|
z-index: 100;
|
||||||
min-width: 200px; min-height: 150px;
|
min-width: 200px; min-height: 150px;
|
||||||
background: var(--bg-board);
|
background: var(--bg-board);
|
||||||
border: 1px solid var(--border-accent);
|
border: 1px solid var(--border-accent);
|
||||||
@@ -1056,6 +1056,314 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
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);
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
WIDGET TOOLBAR
|
WIDGET TOOLBAR
|
||||||
============================================ */
|
============================================ */
|
||||||
@@ -1064,7 +1372,7 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
right: 16px;
|
right: 16px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
z-index: 90;
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -1492,6 +1800,7 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
.search-bar { max-width: 400px; }
|
.search-bar { max-width: 400px; }
|
||||||
.widget-toolbar-btn { width: 32px; height: 32px; }
|
.widget-toolbar-btn { width: 32px; height: 32px; }
|
||||||
.notebook-panel { width: 320px; }
|
.notebook-panel { width: 320px; }
|
||||||
|
.calc-btn { height: 32px; font-size: 13px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smartphone (max 480px) */
|
/* Smartphone (max 480px) */
|
||||||
@@ -1528,6 +1837,7 @@ body.show-desc .bm-desc { display: block; }
|
|||||||
.toolbar-left .widget-toolbar { left: 50%; right: auto; transform: translateX(-50%); }
|
.toolbar-left .widget-toolbar { left: 50%; right: auto; transform: translateX(-50%); }
|
||||||
.widget-toolbar-btn { width: 32px; height: 32px; }
|
.widget-toolbar-btn { width: 32px; height: 32px; }
|
||||||
.notebook-panel { width: 100%; right: -100%; }
|
.notebook-panel { width: 100%; right: -100%; }
|
||||||
|
.calc-btn { height: 30px; font-size: 12px; }
|
||||||
.toolbar-left .notebook-panel { left: -100%; }
|
.toolbar-left .notebook-panel { left: -100%; }
|
||||||
|
|
||||||
.modal { width: calc(100vw - 32px); }
|
.modal { width: calc(100vw - 32px); }
|
||||||
|
|||||||
+5
-1
@@ -18,6 +18,8 @@ async function init() {
|
|||||||
initSearch();
|
initSearch();
|
||||||
await migrateSticky();
|
await migrateSticky();
|
||||||
await Notes.init();
|
await Notes.init();
|
||||||
|
await Calculator.init();
|
||||||
|
await Timer.init();
|
||||||
initDataButtons();
|
initDataButtons();
|
||||||
Store.checkQuota();
|
Store.checkQuota();
|
||||||
|
|
||||||
@@ -98,7 +100,9 @@ async function checkBackupReminder() {
|
|||||||
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
|
// JSON-Export auslösen (gleiche Logik wie btnExportJSON)
|
||||||
const widgetData = await Store.get('widgetStates');
|
const widgetData = await Store.get('widgetStates');
|
||||||
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
const notesData = (widgetData && Array.isArray(widgetData.notes)) ? widgetData.notes : [];
|
||||||
const data = { version: '1.6.0', exported: new Date().toISOString(), boards, settings, notes: notesData };
|
const calcHistory = (widgetData && widgetData.calculator) ? widgetData.calculator.history || [] : [];
|
||||||
|
const timerPresets = (widgetData && widgetData.timer) ? widgetData.timer.presets || [] : [];
|
||||||
|
const data = { version: '1.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');
|
||||||
|
|||||||
@@ -0,0 +1,729 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — calculator.js
|
||||||
|
Taschenrechner Widget: Expression-Parsing,
|
||||||
|
History, Tastatureingabe
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const Calculator = {
|
||||||
|
WIDGET_ID: 'widget_calculator',
|
||||||
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
MAX_HISTORY: 10,
|
||||||
|
|
||||||
|
/** @type {Array<{expr: string, result: string}>} */
|
||||||
|
_history: [],
|
||||||
|
_currentExpr: '',
|
||||||
|
_lastResult: '',
|
||||||
|
_isOpen: false,
|
||||||
|
_displayExprEl: null,
|
||||||
|
_displayResultEl: null,
|
||||||
|
_keydownHandler: null,
|
||||||
|
|
||||||
|
// ---- STORAGE ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator-State aus Storage laden
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.calculator) {
|
||||||
|
this._history = Array.isArray(data.calculator.history) ? data.calculator.history : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator-State in Storage speichern
|
||||||
|
* Bestehende Notes-Daten bleiben erhalten
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY) || {};
|
||||||
|
const notesState = Array.isArray(data.notes) ? data.notes : [];
|
||||||
|
|
||||||
|
// Widget-Position aus WidgetManager holen
|
||||||
|
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||||||
|
const calcData = {
|
||||||
|
x: widgetState ? widgetState.x : 400,
|
||||||
|
y: widgetState ? widgetState.y : 120,
|
||||||
|
width: widgetState ? widgetState.width : 280,
|
||||||
|
height: widgetState ? widgetState.height : 400,
|
||||||
|
open: this._isOpen,
|
||||||
|
history: this._history.slice(0, this.MAX_HISTORY)
|
||||||
|
};
|
||||||
|
|
||||||
|
await Store.set(this.STORAGE_KEY, { notes: notesState, calculator: calcData });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- WIDGET LIFECYCLE ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator oeffnen oder in Vordergrund bringen
|
||||||
|
*/
|
||||||
|
async open() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
WidgetManager.bringToFront(this.WIDGET_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gespeicherte Position laden
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
const saved = (data && data.calculator) ? data.calculator : {};
|
||||||
|
|
||||||
|
const widgetId = WidgetManager.create('calculator', {
|
||||||
|
id: this.WIDGET_ID,
|
||||||
|
title: 'Taschenrechner',
|
||||||
|
x: saved.x || 400,
|
||||||
|
y: saved.y || 120,
|
||||||
|
width: saved.width || 280,
|
||||||
|
height: saved.height || 400,
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = WidgetManager.getBody(widgetId);
|
||||||
|
if (body) this.renderBody(body);
|
||||||
|
|
||||||
|
this._isOpen = true;
|
||||||
|
|
||||||
|
// Keyboard-Events binden
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry) this._bindKeyboard(entry.el);
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator toggle: oeffnen oder minimieren
|
||||||
|
*/
|
||||||
|
async toggle() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry && entry.state.open) {
|
||||||
|
await WidgetManager.minimize(this.WIDGET_ID);
|
||||||
|
this._isOpen = false;
|
||||||
|
await this.save();
|
||||||
|
} else if (entry) {
|
||||||
|
await WidgetManager.openWidget(this.WIDGET_ID);
|
||||||
|
this._isOpen = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird aufgerufen wenn Widget geschlossen wird
|
||||||
|
*/
|
||||||
|
async onClose() {
|
||||||
|
this._isOpen = false;
|
||||||
|
this._unbindKeyboard();
|
||||||
|
this._displayExprEl = null;
|
||||||
|
this._displayResultEl = null;
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- UI RENDERING ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator-Body rendern (in Widget-Body einfuegen)
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
renderBody(bodyEl) {
|
||||||
|
bodyEl.textContent = '';
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
|
||||||
|
// Display
|
||||||
|
const display = document.createElement('div');
|
||||||
|
display.className = 'calc-display';
|
||||||
|
|
||||||
|
const exprEl = document.createElement('div');
|
||||||
|
exprEl.className = 'calc-expression';
|
||||||
|
this._displayExprEl = exprEl;
|
||||||
|
|
||||||
|
const resultEl = document.createElement('div');
|
||||||
|
resultEl.className = 'calc-result';
|
||||||
|
resultEl.textContent = '0';
|
||||||
|
this._displayResultEl = resultEl;
|
||||||
|
|
||||||
|
display.append(exprEl, resultEl);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
const buttonsEl = this._createButtons();
|
||||||
|
|
||||||
|
// History
|
||||||
|
const historyEl = this._createHistoryPanel();
|
||||||
|
|
||||||
|
bodyEl.append(display, buttonsEl, historyEl);
|
||||||
|
|
||||||
|
// Aktuellen State anzeigen
|
||||||
|
this._updateDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button-Grid erstellen (4x5)
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createButtons() {
|
||||||
|
const grid = document.createElement('div');
|
||||||
|
grid.className = 'calc-buttons';
|
||||||
|
|
||||||
|
// Button-Layout: [label, value, cssClass]
|
||||||
|
const buttons = [
|
||||||
|
['C', 'clear', 'clear'],
|
||||||
|
['()', 'paren', 'operator'],
|
||||||
|
['%', '%', 'operator'],
|
||||||
|
['\u00F7', '/', 'operator'],
|
||||||
|
['7', '7', ''],
|
||||||
|
['8', '8', ''],
|
||||||
|
['9', '9', ''],
|
||||||
|
['\u00D7', '*', 'operator'],
|
||||||
|
['4', '4', ''],
|
||||||
|
['5', '5', ''],
|
||||||
|
['6', '6', ''],
|
||||||
|
['\u2212', '-', 'operator'],
|
||||||
|
['1', '1', ''],
|
||||||
|
['2', '2', ''],
|
||||||
|
['3', '3', ''],
|
||||||
|
['+', '+', 'operator'],
|
||||||
|
['0', '0', ''],
|
||||||
|
['.', '.', ''],
|
||||||
|
['\u232B', 'backspace', ''],
|
||||||
|
['=', '=', 'equals']
|
||||||
|
];
|
||||||
|
|
||||||
|
buttons.forEach(([label, value, cls]) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'calc-btn' + (cls ? ' ' + cls : '');
|
||||||
|
btn.textContent = label;
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.addEventListener('click', () => this._handleKey(value));
|
||||||
|
grid.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grid;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History-Panel erstellen
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createHistoryPanel() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'calc-history';
|
||||||
|
container.id = 'calcHistoryPanel';
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'calc-history-title';
|
||||||
|
title.textContent = 'History';
|
||||||
|
container.appendChild(title);
|
||||||
|
|
||||||
|
this._renderHistoryItems(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History-Items rendern
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
_renderHistoryItems(container) {
|
||||||
|
// Alte Items entfernen (nur die .calc-history-item Elemente)
|
||||||
|
const oldItems = container.querySelectorAll('.calc-history-item');
|
||||||
|
oldItems.forEach(item => item.remove());
|
||||||
|
|
||||||
|
if (this._history.length === 0) return;
|
||||||
|
|
||||||
|
// Neueste zuerst
|
||||||
|
const reversed = [...this._history].reverse();
|
||||||
|
reversed.forEach(entry => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'calc-history-item';
|
||||||
|
|
||||||
|
const exprSpan = document.createElement('span');
|
||||||
|
exprSpan.textContent = entry.expr;
|
||||||
|
|
||||||
|
const resultSpan = document.createElement('span');
|
||||||
|
resultSpan.className = 'calc-h-result';
|
||||||
|
resultSpan.textContent = '= ' + entry.result;
|
||||||
|
|
||||||
|
item.append(exprSpan, resultSpan);
|
||||||
|
|
||||||
|
// Klick uebernimmt Ergebnis als neue Eingabe
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this._currentExpr = entry.result;
|
||||||
|
this._lastResult = '';
|
||||||
|
this._updateDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- INPUT HANDLING ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Taste verarbeiten
|
||||||
|
* @param {string} key
|
||||||
|
*/
|
||||||
|
_handleKey(key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'clear':
|
||||||
|
this._currentExpr = '';
|
||||||
|
this._lastResult = '';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'backspace':
|
||||||
|
this._currentExpr = this._currentExpr.slice(0, -1);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case '=':
|
||||||
|
this._calculate();
|
||||||
|
return;
|
||||||
|
|
||||||
|
case 'paren': {
|
||||||
|
// Smarte Klammern: oeffnende wenn noetig, sonst schliessende
|
||||||
|
const openCount = (this._currentExpr.match(/\(/g) || []).length;
|
||||||
|
const closeCount = (this._currentExpr.match(/\)/g) || []).length;
|
||||||
|
const lastChar = this._currentExpr.slice(-1);
|
||||||
|
if (openCount <= closeCount || /[+\-*/%(]$/.test(lastChar) || this._currentExpr === '') {
|
||||||
|
this._currentExpr += '(';
|
||||||
|
} else {
|
||||||
|
this._currentExpr += ')';
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '%':
|
||||||
|
case '+':
|
||||||
|
case '-':
|
||||||
|
case '*':
|
||||||
|
case '/': {
|
||||||
|
// Wenn gerade ein Ergebnis angezeigt wird, damit weiterrechnen
|
||||||
|
if (this._lastResult && this._currentExpr === '') {
|
||||||
|
this._currentExpr = this._lastResult;
|
||||||
|
this._lastResult = '';
|
||||||
|
}
|
||||||
|
// Doppelte Operatoren verhindern (letzten ersetzen)
|
||||||
|
const last = this._currentExpr.slice(-1);
|
||||||
|
if (/[+\-*/%]/.test(last)) {
|
||||||
|
this._currentExpr = this._currentExpr.slice(0, -1) + key;
|
||||||
|
} else {
|
||||||
|
this._currentExpr += key;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case '.': {
|
||||||
|
// Doppelten Dezimalpunkt im letzten Zahlenblock verhindern
|
||||||
|
const parts = this._currentExpr.split(/[+\-*/%()]/);
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
if (lastPart && lastPart.includes('.')) break;
|
||||||
|
this._currentExpr += key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Ziffern 0-9
|
||||||
|
if (/^[0-9]$/.test(key)) {
|
||||||
|
// Wenn ein Ergebnis da ist und User eine Zahl tippt, neue Berechnung starten
|
||||||
|
if (this._lastResult && this._currentExpr === '') {
|
||||||
|
this._lastResult = '';
|
||||||
|
}
|
||||||
|
this._currentExpr += key;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechnung ausfuehren
|
||||||
|
*/
|
||||||
|
async _calculate() {
|
||||||
|
if (!this._currentExpr) return;
|
||||||
|
|
||||||
|
const result = this._evaluate(this._currentExpr);
|
||||||
|
if (result === null) {
|
||||||
|
this._lastResult = 'Fehler';
|
||||||
|
this._updateDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultStr = this._formatResult(result);
|
||||||
|
this._addHistory(this._currentExpr, resultStr);
|
||||||
|
this._lastResult = resultStr;
|
||||||
|
|
||||||
|
// Display aktualisieren
|
||||||
|
if (this._displayExprEl) {
|
||||||
|
this._displayExprEl.textContent = this._formatExpression(this._currentExpr) + ' =';
|
||||||
|
}
|
||||||
|
if (this._displayResultEl) {
|
||||||
|
this._displayResultEl.textContent = resultStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._currentExpr = '';
|
||||||
|
|
||||||
|
// History-Panel aktualisieren
|
||||||
|
const historyPanel = document.getElementById('calcHistoryPanel');
|
||||||
|
if (historyPanel) this._renderHistoryItems(historyPanel);
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- EXPRESSION PARSER (Shunting-Yard, KEIN eval!) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expression sicher auswerten
|
||||||
|
* @param {string} expr
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
_evaluate(expr) {
|
||||||
|
try {
|
||||||
|
// Nur erlaubte Zeichen
|
||||||
|
const sanitized = expr.replace(/[^0-9+\-*/.%()]/g, '');
|
||||||
|
if (!sanitized) return null;
|
||||||
|
|
||||||
|
const tokens = this._tokenize(sanitized);
|
||||||
|
if (!tokens) return null;
|
||||||
|
|
||||||
|
return this._parseExpression(tokens);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expression in Tokens aufteilen
|
||||||
|
* @param {string} expr
|
||||||
|
* @returns {Array|null}
|
||||||
|
*/
|
||||||
|
_tokenize(expr) {
|
||||||
|
const tokens = [];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < expr.length) {
|
||||||
|
const ch = expr[i];
|
||||||
|
|
||||||
|
// Zahl (inkl. Dezimal)
|
||||||
|
if (/[0-9.]/.test(ch)) {
|
||||||
|
let num = '';
|
||||||
|
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
||||||
|
num += expr[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
const parsed = parseFloat(num);
|
||||||
|
if (isNaN(parsed)) return null;
|
||||||
|
tokens.push({ type: 'number', value: parsed });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator
|
||||||
|
if (/[+\-*/%]/.test(ch)) {
|
||||||
|
// Negativer Vorzeichen-Check: am Anfang oder nach Operator/oeffnender Klammer
|
||||||
|
if (ch === '-') {
|
||||||
|
const prev = tokens[tokens.length - 1];
|
||||||
|
if (!prev || prev.type === 'op' || (prev.type === 'paren' && prev.value === '(')) {
|
||||||
|
// Negatives Vorzeichen → als Teil der naechsten Zahl lesen
|
||||||
|
let num = '-';
|
||||||
|
i++;
|
||||||
|
while (i < expr.length && /[0-9.]/.test(expr[i])) {
|
||||||
|
num += expr[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
if (num === '-') return null;
|
||||||
|
const parsed = parseFloat(num);
|
||||||
|
if (isNaN(parsed)) return null;
|
||||||
|
tokens.push({ type: 'number', value: parsed });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push({ type: 'op', value: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Klammern
|
||||||
|
if (ch === '(' || ch === ')') {
|
||||||
|
tokens.push({ type: 'paren', value: ch });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unbekanntes Zeichen
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rekursiver Descent Parser mit Operator-Precedence
|
||||||
|
* @param {Array} tokens
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
_parseExpression(tokens) {
|
||||||
|
let pos = 0;
|
||||||
|
|
||||||
|
function peek() { return tokens[pos]; }
|
||||||
|
function consume() { return tokens[pos++]; }
|
||||||
|
|
||||||
|
// Expression: Term (('+' | '-') Term)*
|
||||||
|
function parseExpr() {
|
||||||
|
let left = parseTerm();
|
||||||
|
if (left === null) return null;
|
||||||
|
|
||||||
|
while (pos < tokens.length) {
|
||||||
|
const t = peek();
|
||||||
|
if (!t || t.type !== 'op' || (t.value !== '+' && t.value !== '-')) break;
|
||||||
|
consume();
|
||||||
|
const right = parseTerm();
|
||||||
|
if (right === null) return null;
|
||||||
|
left = t.value === '+' ? left + right : left - right;
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Term: Factor (('*' | '/' | '%') Factor)*
|
||||||
|
function parseTerm() {
|
||||||
|
let left = parseFactor();
|
||||||
|
if (left === null) return null;
|
||||||
|
|
||||||
|
while (pos < tokens.length) {
|
||||||
|
const t = peek();
|
||||||
|
if (!t || t.type !== 'op' || (t.value !== '*' && t.value !== '/' && t.value !== '%')) break;
|
||||||
|
consume();
|
||||||
|
const right = parseFactor();
|
||||||
|
if (right === null) return null;
|
||||||
|
if (t.value === '*') {
|
||||||
|
left = left * right;
|
||||||
|
} else if (t.value === '/') {
|
||||||
|
if (right === 0) return null;
|
||||||
|
left = left / right;
|
||||||
|
} else {
|
||||||
|
left = left % right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factor: Number | '(' Expression ')'
|
||||||
|
function parseFactor() {
|
||||||
|
const t = peek();
|
||||||
|
if (!t) return null;
|
||||||
|
|
||||||
|
if (t.type === 'number') {
|
||||||
|
consume();
|
||||||
|
return t.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.type === 'paren' && t.value === '(') {
|
||||||
|
consume();
|
||||||
|
const val = parseExpr();
|
||||||
|
if (val === null) return null;
|
||||||
|
const closing = peek();
|
||||||
|
if (closing && closing.type === 'paren' && closing.value === ')') {
|
||||||
|
consume();
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseExpr();
|
||||||
|
|
||||||
|
// Alle Tokens muessen verbraucht sein
|
||||||
|
if (pos < tokens.length) return null;
|
||||||
|
|
||||||
|
if (result === null || !isFinite(result)) return null;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- FORMATTING ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis formatieren (maximal 10 Dezimalstellen, trailing Nullen entfernen)
|
||||||
|
* @param {number} num
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_formatResult(num) {
|
||||||
|
if (Number.isInteger(num)) return num.toString();
|
||||||
|
// Maximal 10 Dezimalstellen, trailing Nullen weg
|
||||||
|
const str = num.toFixed(10).replace(/\.?0+$/, '');
|
||||||
|
return str;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expression fuer Anzeige formatieren (× statt *, ÷ statt /)
|
||||||
|
* @param {string} expr
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
_formatExpression(expr) {
|
||||||
|
return expr
|
||||||
|
.replace(/\*/g, '\u00D7')
|
||||||
|
.replace(/\//g, '\u00F7');
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- DISPLAY ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display aktualisieren
|
||||||
|
*/
|
||||||
|
_updateDisplay() {
|
||||||
|
if (this._displayExprEl) {
|
||||||
|
if (this._lastResult) {
|
||||||
|
// Ergebnis-Modus: Expression oben, Ergebnis gross
|
||||||
|
// (wird von _calculate() direkt gesetzt)
|
||||||
|
} else {
|
||||||
|
this._displayExprEl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._displayResultEl) {
|
||||||
|
if (this._lastResult && this._currentExpr === '') {
|
||||||
|
this._displayResultEl.textContent = this._lastResult;
|
||||||
|
} else {
|
||||||
|
this._displayResultEl.textContent = this._formatExpression(this._currentExpr) || '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- HISTORY ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* History-Eintrag hinzufuegen
|
||||||
|
* @param {string} expr
|
||||||
|
* @param {string} result
|
||||||
|
*/
|
||||||
|
_addHistory(expr, result) {
|
||||||
|
this._history.push({
|
||||||
|
expr: this._formatExpression(expr),
|
||||||
|
result: result
|
||||||
|
});
|
||||||
|
// Limit einhalten
|
||||||
|
if (this._history.length > this.MAX_HISTORY) {
|
||||||
|
this._history = this._history.slice(-this.MAX_HISTORY);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- KEYBOARD ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tastatur-Events binden
|
||||||
|
* @param {HTMLElement} widgetEl
|
||||||
|
*/
|
||||||
|
_bindKeyboard(widgetEl) {
|
||||||
|
this._unbindKeyboard();
|
||||||
|
|
||||||
|
this._keydownHandler = (e) => {
|
||||||
|
// Nur reagieren wenn Calculator-Widget fokussiert ist
|
||||||
|
// (d.h. nicht wenn User in Textarea/Input tippt)
|
||||||
|
if (e.target.tagName === 'TEXTAREA' || e.target.tagName === 'INPUT') return;
|
||||||
|
if (e.target.contentEditable === 'true') return;
|
||||||
|
|
||||||
|
const key = e.key;
|
||||||
|
let handled = false;
|
||||||
|
|
||||||
|
if (/^[0-9]$/.test(key)) {
|
||||||
|
this._handleKey(key);
|
||||||
|
handled = true;
|
||||||
|
} else if (key === '+' || key === '-' || key === '*' || key === '/') {
|
||||||
|
this._handleKey(key);
|
||||||
|
handled = true;
|
||||||
|
} else if (key === '.') {
|
||||||
|
this._handleKey('.');
|
||||||
|
handled = true;
|
||||||
|
} else if (key === '%') {
|
||||||
|
this._handleKey('%');
|
||||||
|
handled = true;
|
||||||
|
} else if (key === '(' || key === ')') {
|
||||||
|
this._handleKey('paren');
|
||||||
|
handled = true;
|
||||||
|
} else if (key === 'Enter' || key === '=') {
|
||||||
|
this._handleKey('=');
|
||||||
|
handled = true;
|
||||||
|
} else if (key === 'Backspace') {
|
||||||
|
this._handleKey('backspace');
|
||||||
|
handled = true;
|
||||||
|
} else if (key === 'Escape' || key === 'c' || key === 'C') {
|
||||||
|
this._handleKey('clear');
|
||||||
|
handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handled) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
widgetEl.addEventListener('keydown', this._keydownHandler);
|
||||||
|
// Widget fokussierbar machen
|
||||||
|
widgetEl.tabIndex = 0;
|
||||||
|
widgetEl.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard-Events entfernen
|
||||||
|
*/
|
||||||
|
_unbindKeyboard() {
|
||||||
|
if (this._keydownHandler) {
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry) {
|
||||||
|
entry.el.removeEventListener('keydown', this._keydownHandler);
|
||||||
|
}
|
||||||
|
this._keydownHandler = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- INIT ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculator initialisieren (aus app.js aufgerufen)
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
// Wenn Calculator beim letzten Mal offen war, wiederherstellen
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.calculator && data.calculator.open) {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close-Event abfangen: WidgetManager.close() ueberschreiben
|
||||||
|
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||||
|
const self = this;
|
||||||
|
WidgetManager.close = function(id) {
|
||||||
|
origClose(id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimize-Event abfangen
|
||||||
|
const origMinimize = WidgetManager.minimize.bind(WidgetManager);
|
||||||
|
WidgetManager.minimize = async function(id) {
|
||||||
|
await origMinimize(id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open-Event abfangen
|
||||||
|
const origOpen = WidgetManager.openWidget.bind(WidgetManager);
|
||||||
|
WidgetManager.openWidget = async function(id) {
|
||||||
|
await origOpen(id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
// Body neu rendern (war durch minimize entfernt)
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
self.renderBody(body);
|
||||||
|
}
|
||||||
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
+41
-6
@@ -13,11 +13,13 @@ function initDataButtons() {
|
|||||||
btnExport.addEventListener('click', async () => {
|
btnExport.addEventListener('click', async () => {
|
||||||
const widgetData = await Store.get('widgetStates');
|
const widgetData = await Store.get('widgetStates');
|
||||||
const data = {
|
const data = {
|
||||||
version: '1.6.0',
|
version: '1.7.0',
|
||||||
exported: new Date().toISOString(),
|
exported: new Date().toISOString(),
|
||||||
boards,
|
boards,
|
||||||
settings,
|
settings,
|
||||||
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : []
|
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
|
||||||
|
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
|
||||||
|
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
|
||||||
};
|
};
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -60,9 +62,9 @@ function initDataButtons() {
|
|||||||
|
|
||||||
// Notes importieren (falls vorhanden)
|
// Notes importieren (falls vorhanden)
|
||||||
let notesImported = 0;
|
let notesImported = 0;
|
||||||
|
const existingWidgets = await Store.get('widgetStates') || {};
|
||||||
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
||||||
const existingWidgets = await Store.get('widgetStates');
|
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
|
||||||
const existingNotes = (existingWidgets && Array.isArray(existingWidgets.notes)) ? existingWidgets.notes : [];
|
|
||||||
const importNotes = data.notes.filter(n => {
|
const importNotes = data.notes.filter(n => {
|
||||||
if (!n || !n.id || !n.template) return false;
|
if (!n || !n.id || !n.template) return false;
|
||||||
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
|
n.checklistItems = Array.isArray(n.checklistItems) ? n.checklistItems : [];
|
||||||
@@ -73,15 +75,48 @@ function initDataButtons() {
|
|||||||
const toImport = importNotes.slice(0, spaceLeft);
|
const toImport = importNotes.slice(0, spaceLeft);
|
||||||
if (toImport.length > 0) {
|
if (toImport.length > 0) {
|
||||||
const merged = [...existingNotes, ...toImport];
|
const merged = [...existingNotes, ...toImport];
|
||||||
await Store.set('widgetStates', { notes: merged });
|
existingWidgets.notes = merged;
|
||||||
Notes._notes = merged;
|
Notes._notes = merged;
|
||||||
notesImported = toImport.length;
|
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 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)${noteMsg} 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) {
|
||||||
|
|||||||
+14
-1
@@ -44,7 +44,16 @@ const Notes = {
|
|||||||
return note;
|
return note;
|
||||||
});
|
});
|
||||||
|
|
||||||
await Store.set(this.STORAGE_KEY, { notes: merged });
|
// Calculator- und Timer-State beibehalten falls vorhanden
|
||||||
|
const existing = await Store.get(this.STORAGE_KEY);
|
||||||
|
const saveData = { notes: merged };
|
||||||
|
if (existing && existing.calculator) {
|
||||||
|
saveData.calculator = existing.calculator;
|
||||||
|
}
|
||||||
|
if (existing && existing.timer) {
|
||||||
|
saveData.timer = existing.timer;
|
||||||
|
}
|
||||||
|
await Store.set(this.STORAGE_KEY, saveData);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -514,6 +523,10 @@ const Notes = {
|
|||||||
await this.create('text');
|
await this.create('text');
|
||||||
} else if (action === 'new-checklist') {
|
} else if (action === 'new-checklist') {
|
||||||
await this.create('checklist');
|
await this.create('checklist');
|
||||||
|
} else if (action === 'calculator') {
|
||||||
|
Calculator.toggle();
|
||||||
|
} else if (action === 'timer') {
|
||||||
|
Timer.toggle();
|
||||||
} else if (action === 'notebook') {
|
} else if (action === 'notebook') {
|
||||||
this.openNotebook();
|
this.openNotebook();
|
||||||
}
|
}
|
||||||
|
|||||||
+760
@@ -0,0 +1,760 @@
|
|||||||
|
/* =============================================
|
||||||
|
HELLION NEWTAB — timer.js
|
||||||
|
Timer / Countdown Widget: Presets, Alarm,
|
||||||
|
Tab-Titel-Blink
|
||||||
|
============================================= */
|
||||||
|
|
||||||
|
const Timer = {
|
||||||
|
WIDGET_ID: 'widget_timer',
|
||||||
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
MAX_PRESETS: 5,
|
||||||
|
|
||||||
|
/** @type {Array<{name: string, seconds: number}>} */
|
||||||
|
_presets: [],
|
||||||
|
_isOpen: false,
|
||||||
|
_seconds: 0,
|
||||||
|
_remaining: 0,
|
||||||
|
_intervalId: null,
|
||||||
|
_running: false,
|
||||||
|
_finished: false,
|
||||||
|
_blinkIntervalId: null,
|
||||||
|
_originalTitle: '',
|
||||||
|
_keydownHandler: null,
|
||||||
|
_muted: false,
|
||||||
|
|
||||||
|
// UI-Referenzen
|
||||||
|
_timeEl: null,
|
||||||
|
_muteBtn: null,
|
||||||
|
_inputEl: null,
|
||||||
|
_inputRow: null,
|
||||||
|
_btnStart: null,
|
||||||
|
_btnPause: null,
|
||||||
|
_btnReset: null,
|
||||||
|
|
||||||
|
// ---- STORAGE ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer-State aus Storage laden
|
||||||
|
*/
|
||||||
|
async load() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.timer) {
|
||||||
|
this._presets = Array.isArray(data.timer.presets) ? data.timer.presets : [];
|
||||||
|
if (typeof data.timer.muted === 'boolean') this._muted = data.timer.muted;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer-State in Storage speichern
|
||||||
|
* Bestehende Notes + Calculator bleiben erhalten
|
||||||
|
*/
|
||||||
|
async save() {
|
||||||
|
const data = await Store.get(this.STORAGE_KEY) || {};
|
||||||
|
if (data.notes === undefined) data.notes = [];
|
||||||
|
|
||||||
|
const widgetState = WidgetManager.getState(this.WIDGET_ID);
|
||||||
|
data.timer = {
|
||||||
|
x: widgetState ? widgetState.x : 600,
|
||||||
|
y: widgetState ? widgetState.y : 80,
|
||||||
|
width: widgetState ? widgetState.width : 260,
|
||||||
|
height: widgetState ? widgetState.height : 360,
|
||||||
|
open: this._isOpen,
|
||||||
|
presets: this._presets.slice(0, this.MAX_PRESETS),
|
||||||
|
muted: this._muted
|
||||||
|
};
|
||||||
|
|
||||||
|
await Store.set(this.STORAGE_KEY, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- WIDGET LIFECYCLE ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer-Widget oeffnen oder in Vordergrund bringen
|
||||||
|
*/
|
||||||
|
async open() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
WidgetManager.bringToFront(this.WIDGET_ID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
const saved = (data && data.timer) ? data.timer : {};
|
||||||
|
|
||||||
|
WidgetManager.create('timer', {
|
||||||
|
id: this.WIDGET_ID,
|
||||||
|
title: 'Timer',
|
||||||
|
x: saved.x || 600,
|
||||||
|
y: saved.y || 80,
|
||||||
|
width: saved.width || 260,
|
||||||
|
height: saved.height || 360,
|
||||||
|
open: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = WidgetManager.getBody(this.WIDGET_ID);
|
||||||
|
if (body) this.renderBody(body);
|
||||||
|
|
||||||
|
this._isOpen = true;
|
||||||
|
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry) this._bindKeyboard(entry.el);
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer toggle: oeffnen oder minimieren
|
||||||
|
*/
|
||||||
|
async toggle() {
|
||||||
|
if (this._isOpen) {
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry && entry.state.open) {
|
||||||
|
await WidgetManager.minimize(this.WIDGET_ID);
|
||||||
|
this._isOpen = false;
|
||||||
|
await this.save();
|
||||||
|
} else if (entry) {
|
||||||
|
await WidgetManager.openWidget(this.WIDGET_ID);
|
||||||
|
this._isOpen = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wird aufgerufen wenn Widget geschlossen wird
|
||||||
|
*/
|
||||||
|
async onClose() {
|
||||||
|
this._isOpen = false;
|
||||||
|
this._unbindKeyboard();
|
||||||
|
this._stopCountdown();
|
||||||
|
this._stopAlarm();
|
||||||
|
this._timeEl = null;
|
||||||
|
this._inputEl = null;
|
||||||
|
this._inputRow = null;
|
||||||
|
this._btnStart = null;
|
||||||
|
this._btnPause = null;
|
||||||
|
this._btnReset = null;
|
||||||
|
this._muteBtn = null;
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- UI RENDERING ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer-Body rendern
|
||||||
|
* @param {HTMLElement} bodyEl
|
||||||
|
*/
|
||||||
|
renderBody(bodyEl) {
|
||||||
|
bodyEl.textContent = '';
|
||||||
|
bodyEl.style.padding = '8px';
|
||||||
|
bodyEl.style.display = 'flex';
|
||||||
|
bodyEl.style.flexDirection = 'column';
|
||||||
|
|
||||||
|
// Display
|
||||||
|
const display = document.createElement('div');
|
||||||
|
display.className = 'timer-display';
|
||||||
|
|
||||||
|
const timeEl = document.createElement('div');
|
||||||
|
timeEl.className = 'timer-time';
|
||||||
|
timeEl.textContent = '00:00';
|
||||||
|
this._timeEl = timeEl;
|
||||||
|
display.appendChild(timeEl);
|
||||||
|
|
||||||
|
// Input
|
||||||
|
const inputRow = document.createElement('div');
|
||||||
|
inputRow.className = 'timer-input-row';
|
||||||
|
this._inputRow = inputRow;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'timer-input';
|
||||||
|
input.type = 'text';
|
||||||
|
input.placeholder = 'mm:ss';
|
||||||
|
input.maxLength = 8;
|
||||||
|
this._inputEl = input;
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._applyInput();
|
||||||
|
this._start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
inputRow.appendChild(input);
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
const controls = document.createElement('div');
|
||||||
|
controls.className = 'timer-controls';
|
||||||
|
|
||||||
|
const btnStart = document.createElement('button');
|
||||||
|
btnStart.className = 'timer-ctrl-btn primary';
|
||||||
|
btnStart.type = 'button';
|
||||||
|
btnStart.textContent = 'Start';
|
||||||
|
btnStart.addEventListener('click', () => {
|
||||||
|
if (!this._running && this._remaining === 0) {
|
||||||
|
this._applyInput();
|
||||||
|
}
|
||||||
|
this._start();
|
||||||
|
});
|
||||||
|
this._btnStart = btnStart;
|
||||||
|
|
||||||
|
const btnPause = document.createElement('button');
|
||||||
|
btnPause.className = 'timer-ctrl-btn';
|
||||||
|
btnPause.type = 'button';
|
||||||
|
btnPause.textContent = 'Pause';
|
||||||
|
btnPause.disabled = true;
|
||||||
|
btnPause.addEventListener('click', () => this._pause());
|
||||||
|
this._btnPause = btnPause;
|
||||||
|
|
||||||
|
const btnReset = document.createElement('button');
|
||||||
|
btnReset.className = 'timer-ctrl-btn danger';
|
||||||
|
btnReset.type = 'button';
|
||||||
|
btnReset.textContent = 'Reset';
|
||||||
|
btnReset.addEventListener('click', () => this._reset());
|
||||||
|
this._btnReset = btnReset;
|
||||||
|
|
||||||
|
controls.append(btnStart, btnPause, btnReset);
|
||||||
|
|
||||||
|
// Mute Toggle (in Controls-Zeile)
|
||||||
|
const muteBtn = document.createElement('button');
|
||||||
|
muteBtn.className = 'timer-mute-btn';
|
||||||
|
muteBtn.type = 'button';
|
||||||
|
this._muteBtn = muteBtn;
|
||||||
|
this._updateMuteBtn();
|
||||||
|
muteBtn.addEventListener('click', async () => {
|
||||||
|
this._muted = !this._muted;
|
||||||
|
this._updateMuteBtn();
|
||||||
|
await this.save();
|
||||||
|
});
|
||||||
|
controls.appendChild(muteBtn);
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
const presetsEl = this._createPresetsPanel();
|
||||||
|
|
||||||
|
bodyEl.append(display, inputRow, controls, presetsEl);
|
||||||
|
|
||||||
|
// State wiederherstellen
|
||||||
|
this._updateDisplay();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presets-Panel erstellen
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
_createPresetsPanel() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.className = 'timer-presets';
|
||||||
|
container.id = 'timerPresetsPanel';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'timer-presets-header';
|
||||||
|
|
||||||
|
const title = document.createElement('span');
|
||||||
|
title.className = 'timer-presets-title';
|
||||||
|
title.textContent = 'Presets';
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.className = 'timer-preset-add';
|
||||||
|
addBtn.type = 'button';
|
||||||
|
addBtn.textContent = '+';
|
||||||
|
addBtn.title = 'Preset speichern';
|
||||||
|
addBtn.addEventListener('click', () => this._showAddPreset(container));
|
||||||
|
|
||||||
|
header.append(title, addBtn);
|
||||||
|
container.appendChild(header);
|
||||||
|
|
||||||
|
this._renderPresetItems(container);
|
||||||
|
|
||||||
|
return container;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset-Items rendern
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
_renderPresetItems(container) {
|
||||||
|
// Alte Items entfernen
|
||||||
|
const oldItems = container.querySelectorAll('.timer-preset-item, .timer-add-row');
|
||||||
|
oldItems.forEach(item => item.remove());
|
||||||
|
|
||||||
|
this._presets.forEach((preset, idx) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'timer-preset-item';
|
||||||
|
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'timer-preset-name';
|
||||||
|
name.textContent = preset.name;
|
||||||
|
|
||||||
|
const time = document.createElement('span');
|
||||||
|
time.className = 'timer-preset-time';
|
||||||
|
time.textContent = this._formatTime(preset.seconds);
|
||||||
|
|
||||||
|
const del = document.createElement('button');
|
||||||
|
del.className = 'timer-preset-del';
|
||||||
|
del.type = 'button';
|
||||||
|
del.textContent = '\u2715';
|
||||||
|
del.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await this._deletePreset(idx);
|
||||||
|
this._renderPresetItems(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
item.append(name, time, del);
|
||||||
|
|
||||||
|
// Klick laedt Preset
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this._loadPreset(preset);
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add-Preset UI anzeigen
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
_showAddPreset(container) {
|
||||||
|
// Nur einmal anzeigen
|
||||||
|
if (container.querySelector('.timer-add-row')) return;
|
||||||
|
|
||||||
|
if (this._presets.length >= this.MAX_PRESETS) {
|
||||||
|
HellionDialog.alert(
|
||||||
|
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_PRESETS + ' Presets speichern.',
|
||||||
|
{ type: 'warning', title: 'Limit erreicht' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aktuelle Zeit als Vorlage
|
||||||
|
const currentSeconds = this._remaining > 0 ? this._seconds : 0;
|
||||||
|
if (currentSeconds === 0 && this._inputEl) {
|
||||||
|
const parsed = this._parseTimeInput(this._inputEl.value);
|
||||||
|
if (parsed === 0) {
|
||||||
|
HellionDialog.alert(
|
||||||
|
'Gib zuerst eine Zeit ein, bevor du ein Preset speicherst.',
|
||||||
|
{ type: 'info', title: 'Keine Zeit' }
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'timer-add-row';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'timer-add-input';
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.placeholder = 'Name...';
|
||||||
|
nameInput.maxLength = 20;
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.className = 'timer-add-confirm';
|
||||||
|
confirmBtn.type = 'button';
|
||||||
|
confirmBtn.textContent = 'OK';
|
||||||
|
|
||||||
|
const doAdd = async () => {
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
let secs = this._seconds;
|
||||||
|
if (secs === 0 && this._inputEl) {
|
||||||
|
secs = this._parseTimeInput(this._inputEl.value);
|
||||||
|
}
|
||||||
|
if (secs === 0) return;
|
||||||
|
|
||||||
|
await this._addPreset(name, secs);
|
||||||
|
this._renderPresetItems(container);
|
||||||
|
};
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', doAdd);
|
||||||
|
nameInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') doAdd();
|
||||||
|
if (e.key === 'Escape') row.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(nameInput, confirmBtn);
|
||||||
|
container.appendChild(row);
|
||||||
|
nameInput.focus();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- TIMER LOGIC ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input-Feld auslesen und als Sekunden setzen
|
||||||
|
*/
|
||||||
|
_applyInput() {
|
||||||
|
if (!this._inputEl) return;
|
||||||
|
const secs = this._parseTimeInput(this._inputEl.value);
|
||||||
|
if (secs > 0) {
|
||||||
|
this._seconds = secs;
|
||||||
|
this._remaining = secs;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer starten
|
||||||
|
*/
|
||||||
|
_start() {
|
||||||
|
if (this._running) return;
|
||||||
|
if (this._remaining <= 0) return;
|
||||||
|
|
||||||
|
// Falls gerade Alarm laeuft, stoppen
|
||||||
|
if (this._finished) {
|
||||||
|
this._stopAlarm();
|
||||||
|
this._finished = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._running = true;
|
||||||
|
this._updateControls();
|
||||||
|
|
||||||
|
// Input verstecken
|
||||||
|
if (this._inputRow) this._inputRow.style.display = 'none';
|
||||||
|
|
||||||
|
this._intervalId = setInterval(() => this._tick(), 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer pausieren
|
||||||
|
*/
|
||||||
|
_pause() {
|
||||||
|
if (!this._running) return;
|
||||||
|
this._running = false;
|
||||||
|
this._stopCountdown();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer zuruecksetzen
|
||||||
|
*/
|
||||||
|
_reset() {
|
||||||
|
this._stopCountdown();
|
||||||
|
this._stopAlarm();
|
||||||
|
this._running = false;
|
||||||
|
this._finished = false;
|
||||||
|
this._remaining = 0;
|
||||||
|
this._seconds = 0;
|
||||||
|
|
||||||
|
// Input wieder anzeigen
|
||||||
|
if (this._inputRow) this._inputRow.style.display = 'flex';
|
||||||
|
if (this._inputEl) this._inputEl.value = '';
|
||||||
|
|
||||||
|
this._updateDisplay();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Jede Sekunde: remaining verringern, Display aktualisieren
|
||||||
|
*/
|
||||||
|
_tick() {
|
||||||
|
this._remaining--;
|
||||||
|
|
||||||
|
if (this._remaining <= 0) {
|
||||||
|
this._remaining = 0;
|
||||||
|
this._stopCountdown();
|
||||||
|
this._running = false;
|
||||||
|
this._finished = true;
|
||||||
|
this._onFinish();
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateDisplay();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval stoppen
|
||||||
|
*/
|
||||||
|
_stopCountdown() {
|
||||||
|
if (this._intervalId) {
|
||||||
|
clearInterval(this._intervalId);
|
||||||
|
this._intervalId = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer abgelaufen — Alarm + Tab-Blink
|
||||||
|
*/
|
||||||
|
_onFinish() {
|
||||||
|
if (!this._muted) this._playAlarm();
|
||||||
|
this._startTitleBlink();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Akustisches Signal (Browser Audio API, kein externer Request)
|
||||||
|
*/
|
||||||
|
_playAlarm() {
|
||||||
|
try {
|
||||||
|
const ctx = new AudioContext();
|
||||||
|
[0, 0.3, 0.6].forEach(delay => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = 800;
|
||||||
|
gain.gain.value = 0.07;
|
||||||
|
osc.start(ctx.currentTime + delay);
|
||||||
|
osc.stop(ctx.currentTime + delay + 0.2);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Timer: Audio nicht verfuegbar', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab-Titel blinken lassen
|
||||||
|
*/
|
||||||
|
_startTitleBlink() {
|
||||||
|
this._originalTitle = document.title;
|
||||||
|
this._blinkIntervalId = setInterval(() => {
|
||||||
|
document.title = document.title === '[!] Timer abgelaufen'
|
||||||
|
? this._originalTitle
|
||||||
|
: '[!] Timer abgelaufen';
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab-Titel Blink und Alarm stoppen
|
||||||
|
*/
|
||||||
|
_stopAlarm() {
|
||||||
|
if (this._blinkIntervalId) {
|
||||||
|
clearInterval(this._blinkIntervalId);
|
||||||
|
this._blinkIntervalId = null;
|
||||||
|
document.title = this._originalTitle || 'Hellion Dashboard';
|
||||||
|
}
|
||||||
|
this._finished = false;
|
||||||
|
this._updateDisplay();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mute-Button Text/Titel aktualisieren
|
||||||
|
*/
|
||||||
|
_updateMuteBtn() {
|
||||||
|
if (!this._muteBtn) return;
|
||||||
|
this._muteBtn.textContent = this._muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
|
||||||
|
this._muteBtn.title = this._muted ? 'Ton einschalten' : 'Ton ausschalten';
|
||||||
|
this._muteBtn.classList.toggle('muted', this._muted);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- DISPLAY ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitanzeige aktualisieren
|
||||||
|
*/
|
||||||
|
_updateDisplay() {
|
||||||
|
if (!this._timeEl) return;
|
||||||
|
this._timeEl.textContent = this._formatTime(this._remaining);
|
||||||
|
this._timeEl.classList.toggle('finished', this._finished);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Button-States aktualisieren
|
||||||
|
*/
|
||||||
|
_updateControls() {
|
||||||
|
if (this._btnStart) {
|
||||||
|
this._btnStart.disabled = this._running;
|
||||||
|
this._btnStart.textContent = this._finished ? 'Neustart' : 'Start';
|
||||||
|
}
|
||||||
|
if (this._btnPause) {
|
||||||
|
this._btnPause.disabled = !this._running;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- PRESETS ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset hinzufuegen
|
||||||
|
* @param {string} name
|
||||||
|
* @param {number} seconds
|
||||||
|
*/
|
||||||
|
async _addPreset(name, seconds) {
|
||||||
|
if (this._presets.length >= this.MAX_PRESETS) return;
|
||||||
|
this._presets.push({ name, seconds });
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset loeschen
|
||||||
|
* @param {number} index
|
||||||
|
*/
|
||||||
|
async _deletePreset(index) {
|
||||||
|
this._presets.splice(index, 1);
|
||||||
|
await this.save();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preset laden (Zeit setzen)
|
||||||
|
* @param {Object} preset - { name, seconds }
|
||||||
|
*/
|
||||||
|
_loadPreset(preset) {
|
||||||
|
// Falls laufend, erst stoppen
|
||||||
|
this._stopCountdown();
|
||||||
|
this._stopAlarm();
|
||||||
|
this._running = false;
|
||||||
|
this._finished = false;
|
||||||
|
|
||||||
|
this._seconds = preset.seconds;
|
||||||
|
this._remaining = preset.seconds;
|
||||||
|
|
||||||
|
if (this._inputRow) this._inputRow.style.display = 'none';
|
||||||
|
|
||||||
|
this._updateDisplay();
|
||||||
|
this._updateControls();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- FORMATTING ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sekunden in Zeitformat umwandeln
|
||||||
|
* @param {number} totalSeconds
|
||||||
|
* @returns {string} "05:30" oder "1:05:30"
|
||||||
|
*/
|
||||||
|
_formatTime(totalSeconds) {
|
||||||
|
const h = Math.floor(totalSeconds / 3600);
|
||||||
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const s = totalSeconds % 60;
|
||||||
|
|
||||||
|
const mm = String(m).padStart(2, '0');
|
||||||
|
const ss = String(s).padStart(2, '0');
|
||||||
|
|
||||||
|
if (h > 0) {
|
||||||
|
return h + ':' + mm + ':' + ss;
|
||||||
|
}
|
||||||
|
return mm + ':' + ss;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeit-String in Sekunden parsen
|
||||||
|
* Akzeptiert: "5:30", "05:30", "1:05:30", "90" (Sekunden)
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
_parseTimeInput(str) {
|
||||||
|
const trimmed = (str || '').trim();
|
||||||
|
if (!trimmed) return 0;
|
||||||
|
|
||||||
|
const parts = trimmed.split(':');
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
// Nur Zahl = Sekunden
|
||||||
|
const secs = parseInt(parts[0], 10);
|
||||||
|
return isNaN(secs) ? 0 : Math.max(0, secs);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
// mm:ss
|
||||||
|
const m = parseInt(parts[0], 10);
|
||||||
|
const s = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(m) || isNaN(s)) return 0;
|
||||||
|
return Math.max(0, m * 60 + s);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
// hh:mm:ss
|
||||||
|
const h = parseInt(parts[0], 10);
|
||||||
|
const m = parseInt(parts[1], 10);
|
||||||
|
const s = parseInt(parts[2], 10);
|
||||||
|
if (isNaN(h) || isNaN(m) || isNaN(s)) return 0;
|
||||||
|
return Math.max(0, h * 3600 + m * 60 + s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- KEYBOARD ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tastatur-Events binden
|
||||||
|
* @param {HTMLElement} widgetEl
|
||||||
|
*/
|
||||||
|
_bindKeyboard(widgetEl) {
|
||||||
|
this._unbindKeyboard();
|
||||||
|
|
||||||
|
this._keydownHandler = (e) => {
|
||||||
|
// Nicht reagieren wenn User in Input tippt
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
|
||||||
|
if (e.key === ' ' || e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this._running) {
|
||||||
|
this._pause();
|
||||||
|
} else if (this._remaining > 0) {
|
||||||
|
this._start();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape' || e.key === 'r' || e.key === 'R') {
|
||||||
|
e.preventDefault();
|
||||||
|
this._reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
widgetEl.addEventListener('keydown', this._keydownHandler);
|
||||||
|
widgetEl.tabIndex = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard-Events entfernen
|
||||||
|
*/
|
||||||
|
_unbindKeyboard() {
|
||||||
|
if (this._keydownHandler) {
|
||||||
|
const entry = WidgetManager._widgets.get(this.WIDGET_ID);
|
||||||
|
if (entry) {
|
||||||
|
entry.el.removeEventListener('keydown', this._keydownHandler);
|
||||||
|
}
|
||||||
|
this._keydownHandler = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- INIT ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timer initialisieren (aus app.js aufgerufen)
|
||||||
|
*/
|
||||||
|
async init() {
|
||||||
|
await this.load();
|
||||||
|
|
||||||
|
// Wenn Timer beim letzten Mal offen war, wiederherstellen
|
||||||
|
const data = await Store.get(this.STORAGE_KEY);
|
||||||
|
if (data && data.timer && data.timer.open) {
|
||||||
|
await this.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close-Event abfangen
|
||||||
|
const origClose = WidgetManager.close.bind(WidgetManager);
|
||||||
|
const self = this;
|
||||||
|
const prevClose = WidgetManager.close;
|
||||||
|
WidgetManager.close = function(id) {
|
||||||
|
prevClose.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self.onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Minimize-Event abfangen
|
||||||
|
const prevMinimize = WidgetManager.minimize;
|
||||||
|
WidgetManager.minimize = async function(id) {
|
||||||
|
await prevMinimize.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = false;
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Open-Event abfangen
|
||||||
|
const prevOpen = WidgetManager.openWidget;
|
||||||
|
WidgetManager.openWidget = async function(id) {
|
||||||
|
await prevOpen.call(WidgetManager, id);
|
||||||
|
if (id === self.WIDGET_ID) {
|
||||||
|
self._isOpen = true;
|
||||||
|
const body = WidgetManager.getBody(self.WIDGET_ID);
|
||||||
|
if (body && body.children.length === 0) {
|
||||||
|
self.renderBody(body);
|
||||||
|
}
|
||||||
|
const entry = WidgetManager._widgets.get(self.WIDGET_ID);
|
||||||
|
if (entry) self._bindKeyboard(entry.el);
|
||||||
|
await self.save();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
const WidgetManager = {
|
const WidgetManager = {
|
||||||
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
|
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
|
||||||
_widgets: new Map(),
|
_widgets: new Map(),
|
||||||
_topZ: 51,
|
_topZ: 100,
|
||||||
STORAGE_KEY: 'widgetStates',
|
STORAGE_KEY: 'widgetStates',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user