diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 246eb29..27963af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,7 @@ jobs: run: | mkdir -p dist zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-chrome.zip" \ - manifest.json newtab.html src/js/*.js src/css/ assets/ \ + manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \ -x "*.git*" "dist/*" ".github/*" "src/js/opera/*" - name: Create Firefox ZIP (Manifest V3) @@ -33,7 +33,7 @@ jobs: cp manifest.json manifest.chrome-backup.json cp manifest.firefox.json manifest.json zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-firefox.zip" \ - manifest.json newtab.html src/js/*.js src/css/ assets/ \ + manifest.json newtab.html src/js/*.js src/css/ assets/ _locales/ \ -x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.firefox.json" "src/js/opera/*" mv manifest.chrome-backup.json manifest.json @@ -42,7 +42,7 @@ jobs: cp manifest.json manifest.chrome-backup.json cp manifest.opera.json manifest.json zip -r "dist/hellion-newtab-${{ steps.version.outputs.tag }}-opera.zip" \ - manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ \ + manifest.json newtab.html src/js/*.js src/js/opera/ src/css/ assets/ _locales/ \ -x "*.git*" "dist/*" ".github/*" "manifest.chrome-backup.json" "manifest.opera.json" mv manifest.chrome-backup.json manifest.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 79db7e6..9f142e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ All notable changes per version. Format based on [Keep a Changelog](https://keep --- +### v2.0.1 — 16.04.2026 + +#### Security + +- **Background URL validation** — Only `blob:` and `data:image/` protocols allowed in CSS `backgroundImage` (prevents CSS injection via manipulated storage) +- **Import URL validation** — `javascript:`, `data:`, and other unsafe protocols are blocked during JSON import +- **Immutable import mapping** — Imported boards, bookmarks, and notes are sanitized with explicit field selection and string length limits + +#### Fixed + +- **Widget minimize race condition** — Replaced `setTimeout` with `transitionend` event; `openWidget()` during animation no longer causes display glitch +- **Notes import mutation** — Import now uses `Notes.init()` instead of directly setting `Notes._notes` +- **Complete i18n coverage** — 5 header button tooltips and 3 settings button texts now have `data-i18n` attributes (10 new translation keys) + +#### Changed + +- **Widget event system** — `WidgetManager` now dispatches `widget:close`, `widget:minimize`, `widget:open` CustomEvents via `EventTarget`. Calculator, Timer, and ImageRef use `WidgetManager.on()` instead of monkey-patching +- **Local favicon icons** — Replaced Google Favicons API with local colored letter icons (deterministic hue per title). Zero external network requests, Brave Shields compatible +- **backdrop-filter fallback** — `@supports not (backdrop-filter)` block with `--bg-solid-fallback` per theme for Brave Shields compatibility +- **Clock interval cleanup** — `setInterval` ID stored in variable + +--- + ### v2.0.0 — 22.03.2026 #### New Features diff --git a/manifest.firefox.json b/manifest.firefox.json index 1bcae52..af05674 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "default_locale": "en", - "version": "2.0.0", + "version": "2.0.1", "description": "__MSG_extDesc__", "author": "Hellion Online Media - Florian Wathling", "homepage_url": "https://hellion-media.de", diff --git a/manifest.json b/manifest.json index ed2dfeb..bb4466e 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "default_locale": "en", - "version": "2.0.0", + "version": "2.0.1", "description": "__MSG_extDesc__", "author": "Hellion Online Media - Florian Wathling", "homepage_url": "https://hellion-media.de", diff --git a/manifest.opera.json b/manifest.opera.json index f0b9cbc..7cd00a5 100644 --- a/manifest.opera.json +++ b/manifest.opera.json @@ -2,7 +2,7 @@ "manifest_version": 3, "name": "__MSG_extName__", "default_locale": "en", - "version": "2.0.0", + "version": "2.0.1", "description": "__MSG_extDesc__", "author": "Hellion Online Media - Florian Wathling", "homepage_url": "https://hellion-media.de", diff --git a/newtab.html b/newtab.html index bdc7daf..df339a3 100644 --- a/newtab.html +++ b/newtab.html @@ -23,23 +23,23 @@
- - - - - @@ -195,7 +195,7 @@ Onboarding wiederholen Willkommens-Tour erneut anzeigen
- + @@ -212,7 +212,7 @@ Alles zurücksetzen Löscht alle Boards, Notes und Einstellungen - + @@ -223,7 +223,7 @@ diff --git a/src/css/main.css b/src/css/main.css index 33f0f3f..9f0045d 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -68,6 +68,19 @@ --board-hover-border: rgba(179,89,255,0.18); --toggle-on-bg: rgba(214,92,255,0.22); --logo-shadow: rgba(179,89,255,0.35); + --bg-solid-fallback: #0a060e; +} + +/* Fallback fuer Browser die backdrop-filter blockieren (z.B. Brave Shields) */ +@supports not (backdrop-filter: blur(1px)) { + .board, + .widget, + .settings-panel, + .dialog-box, + .theme-modal, + .search-bar { + background-color: var(--bg-solid-fallback, var(--bg-primary)); + } } /* ============================================ @@ -91,6 +104,7 @@ --board-hover-border: rgba(179,89,255,0.18); --toggle-on-bg: rgba(214,92,255,0.22); --logo-shadow: rgba(179,89,255,0.35); + --bg-solid-fallback: #0a060e; } [data-theme="nebula"] .board { border-color: rgba(214,92,255,0.10); } [data-theme="nebula"] .bm-item:hover { background: rgba(214,92,255,0.05); } @@ -116,6 +130,7 @@ --board-hover-border: rgba(212, 189, 138, 0.20); --toggle-on-bg: rgba(200,168,74,0.22); --logo-shadow: rgba(212, 189, 138, 0.40); + --bg-solid-fallback: #080c16; letter-spacing: 0.5px; } @@ -146,6 +161,7 @@ --board-hover-border: rgba(157, 92, 255, 0.22); --toggle-on-bg: rgba(224,128,48,0.22); --logo-shadow: rgba(157, 92, 255, 0.45); + --bg-solid-fallback: #08050f; } [data-theme="event-horizon"] .board { border-color: rgba(157, 92, 255, 0.15); } [data-theme="event-horizon"] .bm-item:hover { background: rgba(157, 92, 255, 0.08); } @@ -172,6 +188,7 @@ --board-hover-border: rgba(46, 184, 184, 0.20); --toggle-on-bg: rgba(78,207,207,0.22); --logo-shadow: rgba(46, 184, 184, 0.45); + --bg-solid-fallback: #060a0a; } [data-theme="merchantman"] .board { border-color: rgba(46, 184, 184, 0.12); } [data-theme="merchantman"] .bm-item:hover { background: rgba(46, 184, 184, 0.06); } @@ -197,6 +214,7 @@ --board-hover-border: rgba(125, 179, 255, 0.22); --toggle-on-bg: rgba(91,159,255,0.22); --logo-shadow: rgba(125, 179, 255, 0.50); + --bg-solid-fallback: #070a14; } [data-theme="julia-jin"] .logo { font-family: 'Cinzel', serif; letter-spacing: 5px; text-transform: uppercase; } [data-theme="julia-jin"] .clock { font-family: 'Cinzel', serif; font-weight: 600; } @@ -225,6 +243,7 @@ --board-hover-border: rgba(255, 140, 61, 0.22); --toggle-on-bg: rgba(240,124,48,0.22); --logo-shadow: rgba(255, 140, 61, 0.45); + --bg-solid-fallback: #0f0a08; } [data-theme="sc-sunset"] .board { border-color: rgba(255, 140, 61, 0.15); @@ -253,6 +272,7 @@ --board-hover-border: rgba(50, 255, 106, 0.20); --toggle-on-bg: rgba(34,204,68,0.20); --logo-shadow: rgba(50, 255, 106, 0.40); + --bg-solid-fallback: #050805; --danger: #ff4d4d; } [data-theme="hellion-hud"] .board { @@ -287,6 +307,7 @@ --board-hover-border: rgba(30, 255, 142, 0.25); --toggle-on-bg: rgba(0,232,122,0.18); --logo-shadow: rgba(30, 255, 142, 0.60); + --bg-solid-fallback: #040705; } [data-theme="hellion-energy"] .board { border-color: rgba(30, 255, 142, 0.15); @@ -322,6 +343,7 @@ --board-hover-border: rgba(0, 180, 216, 0.25); --toggle-on-bg: rgba(0, 180, 216, 0.20); --logo-shadow: rgba(0, 180, 216, 0.40); + --bg-solid-fallback: #1a0f08; } [data-theme="satisfactory"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 3px; text-transform: uppercase; } [data-theme="satisfactory"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); } @@ -352,6 +374,7 @@ --board-hover-border: rgba(46, 196, 160, 0.22); --toggle-on-bg: rgba(46, 196, 160, 0.18); --logo-shadow: rgba(46, 196, 160, 0.50); + --bg-solid-fallback: #020d0c; } [data-theme="avorion"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 500; letter-spacing: 6px; text-transform: uppercase; } [data-theme="avorion"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 400; color: var(--accent); } @@ -382,6 +405,7 @@ --board-hover-border: rgba(94, 194, 255, 0.25); --toggle-on-bg: rgba(94, 194, 255, 0.20); --logo-shadow: rgba(94, 194, 255, 0.45); + --bg-solid-fallback: #0d0f12; } [data-theme="hellion-stealth"] .logo { font-family: 'Rajdhani', sans-serif; font-weight: 700; letter-spacing: 4px; } [data-theme="hellion-stealth"] .clock { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--accent); } @@ -562,12 +586,13 @@ html, body { body.compact .bm-item { padding: var(--spacing-compact) 10px; } -.bm-favicon { width: 14px; height: 14px; flex-shrink: 0; border-radius: 2px; opacity: 0.85; } -.bm-favicon-fallback { - width: 14px; height: 14px; flex-shrink: 0; - background: var(--accent-dim); border-radius: 2px; +.bm-favicon-local { + width: 16px; height: 16px; flex-shrink: 0; + border-radius: 3px; display: flex; align-items: center; justify-content: center; - font-size: 8px; color: var(--accent); + font-size: 9px; font-weight: 600; + color: #fff; + line-height: 1; } .bm-text { flex: 1; min-width: 0; } .bm-title { font-size: 12px; font-weight: 400; color: var(--text-primary); line-height: 1.3; } diff --git a/src/js/app.js b/src/js/app.js index dd16d25..9e143e4 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -105,7 +105,7 @@ async function checkBackupReminder() { 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: '2.0.0', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets }; + const data = { version: '2.0.1', exported: new Date().toISOString(), boards, settings, notes: notesData, calculator: calcHistory, timerPresets }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -132,7 +132,7 @@ function startClock() { `${t(DAY_KEYS[now.getDay()])}, ${String(now.getDate()).padStart(2,'0')}. ${t(MONTH_KEYS[now.getMonth()])}`; } tick(); - setInterval(tick, 1000); + const clockInterval = setInterval(tick, 1000); } // ---- GLOBALE EVENTS (Header-Buttons, Modals, Import) ---- diff --git a/src/js/boards.js b/src/js/boards.js index 7e7d5fc..44aaa82 100644 --- a/src/js/boards.js +++ b/src/js/boards.js @@ -215,19 +215,11 @@ function createBmEl(bm) { li.dataset.bmUrl = bm.url; li.draggable = true; - const favicon = document.createElement('img'); - favicon.className = 'bm-favicon'; - favicon.width = 14; - favicon.height = 14; - favicon.src = getFaviconUrl(bm.url); - favicon.addEventListener('error', function() { - this.classList.add('hidden'); - this.nextElementSibling.classList.remove('hidden'); - }); - - const fallback = document.createElement('div'); - fallback.className = 'bm-favicon-fallback hidden'; - fallback.textContent = bm.title.charAt(0).toUpperCase(); + const favicon = document.createElement('div'); + favicon.className = 'bm-favicon-local'; + favicon.textContent = bm.title.charAt(0).toUpperCase(); + const hue = (bm.title.charCodeAt(0) * 137) % 360; + favicon.style.backgroundColor = `hsl(${hue}, 45%, 35%)`; const textDiv = document.createElement('div'); textDiv.className = 'bm-text'; @@ -247,7 +239,6 @@ function createBmEl(bm) { deleteBtn.textContent = '✕'; li.appendChild(favicon); - li.appendChild(fallback); li.appendChild(textDiv); li.appendChild(deleteBtn); diff --git a/src/js/calculator.js b/src/js/calculator.js index b2f2f5b..f8090ed 100644 --- a/src/js/calculator.js +++ b/src/js/calculator.js @@ -689,41 +689,32 @@ const Calculator = { await this.open(); } - // Close-Event abfangen: WidgetManager.close() ueberschreiben - const origClose = WidgetManager.close.bind(WidgetManager); + // Widget-Lifecycle-Events const self = this; - WidgetManager.close = function(id) { - origClose(id); - if (id === self.WIDGET_ID) { + WidgetManager.on('widget:close', (e) => { + if (e.detail.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) { + WidgetManager.on('widget:minimize', (e) => { + if (e.detail.id === self.WIDGET_ID) { self._isOpen = false; - await self.save(); + self.save(); } - }; + }); - // Open-Event abfangen - const origOpen = WidgetManager.openWidget.bind(WidgetManager); - WidgetManager.openWidget = async function(id) { - await origOpen(id); - if (id === self.WIDGET_ID) { + WidgetManager.on('widget:open', (e) => { + if (e.detail.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(); + self.save(); } - }; + }); } }; diff --git a/src/js/data.js b/src/js/data.js index 4ccfb83..4b7c14a 100644 --- a/src/js/data.js +++ b/src/js/data.js @@ -9,11 +9,26 @@ function initDataButtons() { const jsonInput = document.getElementById('jsonImportInput'); if (!btnExport || !btnImport) return; + /** + * Prueft ob eine URL ein sicheres Protokoll hat. + * Blockiert javascript:, data:, vbscript: etc. + * @param {string} url + * @returns {boolean} + */ + function isSafeUrl(url) { + try { + const u = new URL(url); + return ['http:', 'https:', 'ftp:'].includes(u.protocol); + } catch { + return false; + } + } + // Export (inkl. Notes) btnExport.addEventListener('click', async () => { const widgetData = await Store.get('widgetStates'); const data = { - version: '2.0.0', + version: '2.0.1', exported: new Date().toISOString(), boards, settings, @@ -38,18 +53,21 @@ function initDataButtons() { try { const data = JSON.parse(await file.text()); if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format')); - const validBoards = data.boards.filter(b => { - if (!b || typeof b.title !== 'string' || !Array.isArray(b.bookmarks)) return false; - b.id = b.id || uid(); - b.blurred = !!b.blurred; - b.bookmarks = b.bookmarks.filter(bm => { - if (!bm || typeof bm.title !== 'string' || typeof bm.url !== 'string') return false; - bm.id = bm.id || uid(); - bm.desc = bm.desc || ''; - return true; - }); - return true; - }); + const validBoards = data.boards + .filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks)) + .map(b => ({ + id: b.id || uid(), + title: String(b.title).slice(0, 100), + blurred: !!b.blurred, + bookmarks: b.bookmarks + .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) + .map(bm => ({ + id: bm.id || uid(), + title: String(bm.title).slice(0, 200), + url: bm.url, + desc: String(bm.desc || '').slice(0, 500) + })) + })); if (validBoards.length === 0) throw new Error(t('data.no_boards')); const ok = await HellionDialog.confirm( t('data.import_confirm', { count: validBoards.length }), @@ -65,18 +83,26 @@ function initDataButtons() { 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; - }); + const importNotes = data.notes + .filter(n => n && n.id && n.template) + .map(n => ({ + id: n.id, + template: ['note', 'checklist'].includes(n.template) ? n.template : 'note', + title: String(n.title || '').slice(0, 200), + content: String(n.content || '').slice(0, 5000), + x: typeof n.x === 'number' ? n.x : 120, + y: typeof n.y === 'number' ? n.y : 80, + width: typeof n.width === 'number' ? n.width : 280, + height: typeof n.height === 'number' ? n.height : 220, + open: n.open !== false, + checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : [] + })); // 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; } } @@ -90,7 +116,6 @@ function initDataButtons() { 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; } } @@ -104,7 +129,6 @@ function initDataButtons() { 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; } } @@ -112,6 +136,11 @@ function initDataButtons() { // Gemeinsam speichern await Store.set('widgetStates', existingWidgets); + // Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals) + if (notesImported > 0) await Notes.init(); + if (calcImported) await Calculator.load(); + if (timerImported) await Timer.load(); + const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : ''; const calcMsg = calcImported ? t('data.calc_suffix') : ''; const timerMsg = timerImported ? t('data.timer_suffix') : ''; diff --git a/src/js/i18n.js b/src/js/i18n.js index 42486ea..313c299 100644 --- a/src/js/i18n.js +++ b/src/js/i18n.js @@ -202,6 +202,13 @@ const STRINGS = { 'header.theme': 'Darstellung', 'header.settings': 'Einstellungen', + // Header Tooltips + 'header.import_title': 'Bookmarks importieren (HTML)', + 'header.board_title': 'Neues Board hinzufügen', + 'header.note_title': 'Schnellnotiz', + 'header.theme_title': 'Darstellung & Theme', + 'header.settings_title': 'Einstellungen', + // Settings-Panel Überschrift 'settings.title': 'Einstellungen', @@ -255,6 +262,13 @@ const STRINGS = { 'settings.bg_upload.desc': 'Lokales Bild als Hintergrund verwenden', 'settings.search_engine_toggle': 'Suchmaschine wechseln', + // Settings Buttons + Validierung + 'settings.onboarding_btn': 'Start', + 'settings.reset_btn': 'Reset', + 'settings.bg_upload_btn': 'Upload', + 'settings.bg_invalid_url': 'Nur lokale Bilder (Upload) sind als Hintergrund erlaubt.', + 'settings.bg_invalid_url.title': 'Ungültige URL', + // Modals 'modal.new_board': 'Neues Board', 'modal.board_name': 'Board-Name...', @@ -493,6 +507,13 @@ const STRINGS = { 'header.theme': 'Theme', 'header.settings': 'Settings', + // Header Tooltips + 'header.import_title': 'Import bookmarks (HTML)', + 'header.board_title': 'Add new board', + 'header.note_title': 'Quick note', + 'header.theme_title': 'Appearance & Theme', + 'header.settings_title': 'Settings', + // Settings panel heading 'settings.title': 'Settings', @@ -546,6 +567,13 @@ const STRINGS = { 'settings.bg_upload.desc': 'Use a local image as background', 'settings.search_engine_toggle': 'Switch search engine', + // Settings Buttons + Validation + 'settings.onboarding_btn': 'Start', + 'settings.reset_btn': 'Reset', + 'settings.bg_upload_btn': 'Upload', + 'settings.bg_invalid_url': 'Only local images (upload) are allowed as background.', + 'settings.bg_invalid_url.title': 'Invalid URL', + // Modals 'modal.new_board': 'New Board', 'modal.board_name': 'Board name...', diff --git a/src/js/image-ref.js b/src/js/image-ref.js index 93a2211..2f52433 100644 --- a/src/js/image-ref.js +++ b/src/js/image-ref.js @@ -460,41 +460,32 @@ const ImageRef = { }); } - // Close-Event abfangen + // Widget-Lifecycle-Events 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); + WidgetManager.on('widget:close', (e) => { + const isImage = self._images.some(img => img.id === e.detail.id); if (isImage) { - self.onClose(id); + self.onClose(e.detail.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); + WidgetManager.on('widget:minimize', (e) => { + const isImage = self._images.some(img => img.id === e.detail.id); if (isImage) { - await self.save(); + 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); + WidgetManager.on('widget:open', (e) => { + const imgData = self._images.find(img => img.id === e.detail.id); if (imgData) { - const body = WidgetManager.getBody(id); + const body = WidgetManager.getBody(e.detail.id); if (body && body.children.length === 0) { - const dataUrl = self._getSessionImage(id); + const dataUrl = self._getSessionImage(e.detail.id); self.renderBody(imgData, body, dataUrl); } - await self.save(); + self.save(); } - }; + }); } }; diff --git a/src/js/settings.js b/src/js/settings.js index 29408e2..b74e276 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -23,6 +23,17 @@ function closeThemeModal() { overlay.classList.remove('active'); } +/** + * Prueft ob eine Background-URL sicher fuer CSS-Einbettung ist. + * Erlaubt nur blob: und data:image/ Protokolle (aus File Upload). + * @param {string} url + * @returns {boolean} + */ +function isValidBgUrl(url) { + return typeof url === 'string' && url.length > 0 && + (url.startsWith('blob:') || url.startsWith('data:image/')); +} + // ---- ACCORDION ---- function initAccordion() { const defaultOpen = new Set(['widgets']); @@ -89,8 +100,10 @@ function applySettings() { applyTheme(settings.theme || 'nebula', !!settings.bgUrl); - if (settings.bgUrl) { + if (settings.bgUrl && isValidBgUrl(settings.bgUrl)) { document.getElementById('bgLayer').style.backgroundImage = `url('${settings.bgUrl}')`; + } else if (settings.bgUrl) { + settings.bgUrl = ''; } } @@ -168,6 +181,10 @@ function bindSettingsEvents() { }); document.getElementById('btnApplyBg').addEventListener('click', async () => { const url = document.getElementById('bgUrlInput').value.trim(); + if (url && !isValidBgUrl(url)) { + await HellionDialog.alert(t('settings.bg_invalid_url'), { type: 'danger', title: t('settings.bg_invalid_url.title') }); + return; + } settings.bgUrl = url; document.getElementById('bgLayer').style.backgroundImage = url ? `url('${url}')` : ''; await saveSettings(); @@ -183,6 +200,7 @@ function bindSettingsEvents() { if (!file) return; const reader = new FileReader(); reader.onload = async ev => { + if (!isValidBgUrl(ev.target.result)) return; settings.bgUrl = ev.target.result; document.getElementById('bgLayer').style.backgroundImage = `url('${ev.target.result}')`; await saveSettings(); diff --git a/src/js/state.js b/src/js/state.js index d387e47..d2db23e 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -33,15 +33,6 @@ function escHtml(str) { .replace(/"/g, '"'); } -function getFaviconUrl(url) { - try { - const u = new URL(url); - return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=16`; - } catch { - return ''; - } -} - function getDefaultBoards() { return [ { diff --git a/src/js/timer.js b/src/js/timer.js index 34ab404..8cb1616 100644 --- a/src/js/timer.js +++ b/src/js/timer.js @@ -720,32 +720,23 @@ const Timer = { await this.open(); } - // Close-Event abfangen - const origClose = WidgetManager.close.bind(WidgetManager); + // Widget-Lifecycle-Events const self = this; - const prevClose = WidgetManager.close; - WidgetManager.close = function(id) { - prevClose.call(WidgetManager, id); - if (id === self.WIDGET_ID) { + WidgetManager.on('widget:close', (e) => { + if (e.detail.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) { + WidgetManager.on('widget:minimize', (e) => { + if (e.detail.id === self.WIDGET_ID) { self._isOpen = false; - await self.save(); + self.save(); } - }; + }); - // Open-Event abfangen - const prevOpen = WidgetManager.openWidget; - WidgetManager.openWidget = async function(id) { - await prevOpen.call(WidgetManager, id); - if (id === self.WIDGET_ID) { + WidgetManager.on('widget:open', (e) => { + if (e.detail.id === self.WIDGET_ID) { self._isOpen = true; const body = WidgetManager.getBody(self.WIDGET_ID); if (body && body.children.length === 0) { @@ -753,8 +744,8 @@ const Timer = { } const entry = WidgetManager._widgets.get(self.WIDGET_ID); if (entry) self._bindKeyboard(entry.el); - await self.save(); + self.save(); } - }; + }); } }; diff --git a/src/js/widgets.js b/src/js/widgets.js index 47ea24d..b16ac56 100644 --- a/src/js/widgets.js +++ b/src/js/widgets.js @@ -9,6 +9,27 @@ const WidgetManager = { _topZ: 100, STORAGE_KEY: 'widgetStates', + /** @type {EventTarget} Internes Event-System fuer Widget-Lifecycle */ + _emitter: new EventTarget(), + + /** + * Event-Listener registrieren + * @param {string} event - z.B. 'widget:close', 'widget:minimize', 'widget:open' + * @param {Function} handler + */ + on(event, handler) { + this._emitter.addEventListener(event, handler); + }, + + /** + * Event-Listener entfernen + * @param {string} event + * @param {Function} handler + */ + off(event, handler) { + this._emitter.removeEventListener(event, handler); + }, + /** * Widget erstellen und in DOM einfuegen * @param {string} type - 'note' @@ -31,7 +52,7 @@ const WidgetManager = { const el = this._buildDOM(state); document.body.appendChild(el); - this._widgets.set(id, { el, type, state }); + this._widgets.set(id, { el, type, state, _minimizing: false }); this._initDrag(el); this._initResize(el); this.bringToFront(id); @@ -144,22 +165,47 @@ const WidgetManager = { const entry = this._widgets.get(id); if (!entry) return; entry.el.remove(); + this._emitter.dispatchEvent(new CustomEvent('widget:close', { detail: { id } })); this._widgets.delete(id); }, /** - * Widget minimieren (aus DOM verstecken, bleibt im Notebook) + * Widget minimieren (aus DOM verstecken, bleibt im Notebook). + * Nutzt transitionend statt setTimeout — _minimizing Flag verhindert Race Condition + * mit openWidget(). Fallback-Timer fuer prefers-reduced-motion / fehlende Transition. * @param {string} id */ async minimize(id) { const entry = this._widgets.get(id); if (!entry) return; entry.state.open = false; + entry._minimizing = true; entry.el.classList.add('widget-minimized'); - setTimeout(() => { - entry.el.style.display = 'none'; - }, 250); + + const MINIMIZE_FALLBACK_MS = 350; + + function onEnd(e) { + if (e.target !== entry.el || e.propertyName !== 'opacity') return; + clearTimeout(fallbackTimer); + entry.el.removeEventListener('transitionend', onEnd); + if (entry._minimizing) { + entry.el.style.display = 'none'; + } + entry._minimizing = false; + } + + entry.el.addEventListener('transitionend', onEnd); + + const fallbackTimer = setTimeout(() => { + entry.el.removeEventListener('transitionend', onEnd); + if (entry._minimizing) { + entry.el.style.display = 'none'; + entry._minimizing = false; + } + }, MINIMIZE_FALLBACK_MS); + await this.save(); + this._emitter.dispatchEvent(new CustomEvent('widget:minimize', { detail: { id } })); }, /** @@ -169,14 +215,15 @@ const WidgetManager = { async openWidget(id) { const entry = this._widgets.get(id); if (!entry) return; + entry._minimizing = false; 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(); + this._emitter.dispatchEvent(new CustomEvent('widget:open', { detail: { id } })); }, /**