diff --git a/newtab.html b/newtab.html index d234357..628c3c7 100644 --- a/newtab.html +++ b/newtab.html @@ -73,6 +73,9 @@ + @@ -197,6 +200,16 @@ +
+
+ Bild-Referenz Widgets + Bilder als Referenz anzeigen (nur aktuelle Session) +
+ +
@@ -483,6 +496,7 @@ + diff --git a/src/css/main.css b/src/css/main.css index 53f4d2c..0ca3164 100644 --- a/src/css/main.css +++ b/src/css/main.css @@ -1364,6 +1364,91 @@ body.show-desc .bm-desc { display: block; } color: var(--bg-primary); } +/* ============================================ + IMAGE REFERENCE WIDGET + ============================================ */ +.imgref-container { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + padding: 8px; +} +.imgref-img-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: var(--radius-sm); + min-height: 80px; +} +.imgref-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: var(--radius-sm); +} +.imgref-dropzone { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border: 2px dashed var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; + min-height: 80px; + gap: 8px; +} +.imgref-dropzone:hover, +.imgref-dropzone.dragover { + border-color: var(--accent); + color: var(--text-secondary); +} +.imgref-dropzone-icon { + font-size: 24px; + opacity: 0.5; +} +.imgref-label { + width: 100%; + margin-top: 8px; + font-size: 11px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + padding: 4px 8px; + flex-shrink: 0; +} +.imgref-label:focus { + border-color: var(--accent); + outline: none; +} +.imgref-label::placeholder { + color: var(--text-muted); +} +.imgref-replace-btn { + width: 100%; + height: 24px; + margin-top: 6px; + font-size: 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + flex-shrink: 0; + transition: all 0.2s; +} +.imgref-replace-btn:hover { + border-color: var(--accent); + color: var(--text-secondary); +} + /* ============================================ WIDGET TOOLBAR ============================================ */ diff --git a/src/js/app.js b/src/js/app.js index 6316f82..5bf4352 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -20,6 +20,7 @@ async function init() { await Notes.init(); await Calculator.init(); await Timer.init(); + await ImageRef.init(); initDataButtons(); Store.checkQuota(); diff --git a/src/js/image-ref.js b/src/js/image-ref.js new file mode 100644 index 0000000..2a1f230 --- /dev/null +++ b/src/js/image-ref.js @@ -0,0 +1,500 @@ +/* ============================================= + HELLION NEWTAB — image-ref.js + Bild-Referenz Widget: Session-only Bildanzeige + mit Canvas API WebP-Konvertierung + ============================================= */ + +const ImageRef = { + MAX_IMAGES: 3, + STORAGE_KEY: 'widgetStates', + SESSION_KEY: 'imageRefData', + + /** @type {Array<{id: string, label: string, x: number, y: number, width: number, height: number, open: boolean}>} */ + _images: [], + _saveTimer: null, + + // ---- STORAGE (persistent: Position/Meta) ---- + + /** + * Widget-Meta aus persistentem Storage laden + */ + async load() { + const data = await Store.get(this.STORAGE_KEY); + if (data && data.imageRef && Array.isArray(data.imageRef.images)) { + this._images = data.imageRef.images; + } + }, + + /** + * Widget-Meta persistent speichern + * Bestehende Notes, Calculator, Timer bleiben erhalten + */ + async save() { + const data = await Store.get(this.STORAGE_KEY) || {}; + if (data.notes === undefined) data.notes = []; + + // Positionen aus WidgetManager aktualisieren + const updated = this._images.map(img => { + const ws = WidgetManager.getState(img.id); + if (ws) { + img.x = ws.x; + img.y = ws.y; + img.width = ws.width; + img.height = ws.height; + img.open = ws.open; + } + return img; + }); + + data.imageRef = { images: updated }; + await Store.set(this.STORAGE_KEY, data); + }, + + /** + * Debounced Save + */ + _debouncedSave() { + clearTimeout(this._saveTimer); + this._saveTimer = setTimeout(() => this.save(), 500); + }, + + // ---- SESSION STORAGE (Bilddaten) ---- + + /** + * Bilddaten in sessionStorage speichern + */ + _saveSession() { + try { + const sessionData = {}; + this._images.forEach(img => { + const dataUrl = this._getSessionImage(img.id); + if (dataUrl) sessionData[img.id] = dataUrl; + }); + sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(sessionData)); + } catch (e) { + console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e); + } + }, + + /** + * Bilddaten aus sessionStorage laden + * @returns {Object} - { id: dataUrl, ... } + */ + _loadSessionAll() { + try { + const raw = sessionStorage.getItem(this.SESSION_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch (e) { + console.warn('ImageRef: sessionStorage Read fehlgeschlagen', e); + return {}; + } + }, + + /** + * Einzelnes Bild aus sessionStorage lesen + * @param {string} id + * @returns {string|null} + */ + _getSessionImage(id) { + const all = this._loadSessionAll(); + return all[id] || null; + }, + + /** + * Einzelnes Bild in sessionStorage setzen + * @param {string} id + * @param {string} dataUrl + */ + _setSessionImage(id, dataUrl) { + try { + const all = this._loadSessionAll(); + all[id] = dataUrl; + sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all)); + } catch (e) { + console.warn('ImageRef: sessionStorage Write fehlgeschlagen', e); + HellionDialog.alert( + 'Bild konnte nicht gespeichert werden. Der Browser-Speicher ist voll.', + { type: 'danger', title: 'Speicherfehler' } + ); + } + }, + + /** + * Einzelnes Bild aus sessionStorage entfernen + * @param {string} id + */ + _removeSessionImage(id) { + try { + const all = this._loadSessionAll(); + delete all[id]; + sessionStorage.setItem(this.SESSION_KEY, JSON.stringify(all)); + } catch (e) { + console.warn('ImageRef: sessionStorage Remove fehlgeschlagen', e); + } + }, + + // ---- WIDGET LIFECYCLE ---- + + /** + * Neues Bild-Widget erstellen (oeffnet File-Dialog) + */ + async create() { + if (!settings.imageRefEnabled) return; + + if (this._images.length >= this.MAX_IMAGES) { + await HellionDialog.alert( + 'Maximal ' + this.MAX_IMAGES + ' Bild-Widgets gleichzeitig. Schliesse eines um ein neues zu oeffnen.', + { type: 'warning', title: 'Limit erreicht' } + ); + return; + } + + // Freie ID finden + const usedIds = new Set(this._images.map(i => i.id)); + let slotId = null; + for (let i = 0; i < this.MAX_IMAGES; i++) { + const candidate = 'image_' + i; + if (!usedIds.has(candidate)) { + slotId = candidate; + break; + } + } + if (!slotId) return; + + // File-Dialog + const file = await this._pickFile(); + if (!file) return; + + // Bild verarbeiten + let dataUrl; + try { + dataUrl = await this._processFile(file); + } catch (err) { + await HellionDialog.alert( + 'Bild konnte nicht geladen werden: ' + err.message, + { type: 'danger', title: 'Bildfehler' } + ); + return; + } + + // In sessionStorage speichern + this._setSessionImage(slotId, dataUrl); + + // Meta erstellen + const imageData = { + id: slotId, + label: '', + x: 200 + (this._images.length * 40), + y: 120 + (this._images.length * 30), + width: 320, + height: 280, + open: true + }; + this._images.push(imageData); + + // Widget erstellen + this._createWidget(imageData, dataUrl); + await this.save(); + }, + + /** + * Widget im DOM erstellen + * @param {Object} imageData + * @param {string|null} dataUrl + */ + _createWidget(imageData, dataUrl) { + WidgetManager.create('image', { + id: imageData.id, + title: imageData.label || 'Bild-Referenz', + x: imageData.x, + y: imageData.y, + width: imageData.width, + height: imageData.height, + open: imageData.open !== false + }); + + const body = WidgetManager.getBody(imageData.id); + if (body) this.renderBody(imageData, body, dataUrl); + }, + + /** + * Widget geschlossen — Daten aufraeumen + * @param {string} id + */ + async onClose(id) { + this._removeSessionImage(id); + this._images = this._images.filter(img => img.id !== id); + await this.save(); + }, + + // ---- UI RENDERING ---- + + /** + * Widget-Body rendern + * @param {Object} imageData + * @param {HTMLElement} bodyEl + * @param {string|null} dataUrl + */ + renderBody(imageData, bodyEl, dataUrl) { + bodyEl.textContent = ''; + const container = document.createElement('div'); + container.className = 'imgref-container'; + + if (dataUrl) { + // Bild anzeigen + const wrapper = document.createElement('div'); + wrapper.className = 'imgref-img-wrapper'; + + const img = document.createElement('img'); + img.className = 'imgref-img'; + img.src = dataUrl; + img.alt = imageData.label || 'Bild-Referenz'; + wrapper.appendChild(img); + + // Bild ersetzen Button + const replaceBtn = document.createElement('button'); + replaceBtn.className = 'imgref-replace-btn'; + replaceBtn.type = 'button'; + replaceBtn.textContent = 'Bild ersetzen'; + replaceBtn.addEventListener('click', async () => { + const file = await this._pickFile(); + if (!file) return; + try { + const newDataUrl = await this._processFile(file); + this._setSessionImage(imageData.id, newDataUrl); + this.renderBody(imageData, bodyEl, newDataUrl); + } catch (err) { + await HellionDialog.alert( + 'Bild konnte nicht geladen werden: ' + err.message, + { type: 'danger', title: 'Bildfehler' } + ); + } + }); + + container.append(wrapper, replaceBtn); + } else { + // Drop-Zone (kein Bild vorhanden) + const dropzone = this._createDropzone(imageData, bodyEl); + container.appendChild(dropzone); + } + + // Label-Input + const label = document.createElement('input'); + label.className = 'imgref-label'; + label.type = 'text'; + label.placeholder = 'Beschriftung (optional)'; + label.maxLength = 100; + label.value = imageData.label || ''; + + label.addEventListener('input', () => { + const text = label.value.trim().slice(0, 100); + imageData.label = text; + + // Widget-Titel aktualisieren + const entry = WidgetManager._widgets.get(imageData.id); + if (entry) { + const titleEl = entry.el.querySelector('.widget-title-text'); + if (titleEl) titleEl.textContent = text || 'Bild-Referenz'; + entry.state.title = text || 'Bild-Referenz'; + } + + this._debouncedSave(); + }); + + container.appendChild(label); + bodyEl.appendChild(container); + }, + + /** + * Drop-Zone erstellen (fuer leere Widgets / neue Bilder) + * @param {Object} imageData + * @param {HTMLElement} bodyEl + * @returns {HTMLElement} + */ + _createDropzone(imageData, bodyEl) { + const dropzone = document.createElement('div'); + dropzone.className = 'imgref-dropzone'; + + const icon = document.createElement('div'); + icon.className = 'imgref-dropzone-icon'; + icon.textContent = '\uD83D\uDDBC\uFE0F'; + + const text = document.createElement('span'); + text.textContent = 'Klicken oder Bild hierher ziehen'; + + dropzone.append(icon, text); + + // Klick -> File-Dialog + dropzone.addEventListener('click', async () => { + const file = await this._pickFile(); + if (!file) return; + try { + const dataUrl = await this._processFile(file); + this._setSessionImage(imageData.id, dataUrl); + this.renderBody(imageData, bodyEl, dataUrl); + await this.save(); + } catch (err) { + await HellionDialog.alert( + 'Bild konnte nicht geladen werden: ' + err.message, + { type: 'danger', title: 'Bildfehler' } + ); + } + }); + + // Drag & Drop + dropzone.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + dropzone.classList.add('dragover'); + }); + + dropzone.addEventListener('dragleave', (e) => { + e.preventDefault(); + e.stopPropagation(); + dropzone.classList.remove('dragover'); + }); + + dropzone.addEventListener('drop', async (e) => { + e.preventDefault(); + e.stopPropagation(); + dropzone.classList.remove('dragover'); + + const file = e.dataTransfer.files[0]; + if (!file || !file.type.startsWith('image/')) { + await HellionDialog.alert( + 'Bitte eine Bilddatei verwenden (PNG, JPG, WebP, etc.).', + { type: 'warning', title: 'Kein Bild' } + ); + return; + } + + try { + const dataUrl = await this._processFile(file); + this._setSessionImage(imageData.id, dataUrl); + this.renderBody(imageData, bodyEl, dataUrl); + await this.save(); + } catch (err) { + await HellionDialog.alert( + 'Bild konnte nicht geladen werden: ' + err.message, + { type: 'danger', title: 'Bildfehler' } + ); + } + }); + + return dropzone; + }, + + // ---- FILE HANDLING ---- + + /** + * File-Dialog oeffnen + * @returns {Promise} + */ + _pickFile() { + return new Promise(resolve => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + input.addEventListener('change', () => { + resolve(input.files[0] || null); + }); + // Cancel erkennen + input.addEventListener('cancel', () => resolve(null)); + input.click(); + }); + }, + + /** + * Bild per Canvas API zu WebP konvertieren + * @param {File} file + * @returns {Promise} WebP DataURL + */ + _processFile(file) { + return new Promise((resolve, reject) => { + const objectUrl = URL.createObjectURL(file); + const img = new Image(); + + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + const webpUrl = canvas.toDataURL('image/webp', 0.85); + URL.revokeObjectURL(objectUrl); + resolve(webpUrl); + } catch (err) { + URL.revokeObjectURL(objectUrl); + reject(err); + } + }; + + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + reject(new Error('Bild konnte nicht geladen werden')); + }; + + img.src = objectUrl; + }); + }, + + // ---- INIT ---- + + /** + * ImageRef initialisieren (aus app.js aufgerufen) + */ + async init() { + await this.load(); + + // Widgets wiederherstellen (nur wenn Feature aktiviert) + if (settings.imageRefEnabled && this._images.length > 0) { + const sessionData = this._loadSessionAll(); + + this._images.forEach(imageData => { + if (imageData.open !== false) { + const dataUrl = sessionData[imageData.id] || null; + this._createWidget(imageData, dataUrl); + } + }); + } + + // Close-Event abfangen + const self = this; + const prevClose = WidgetManager.close; + WidgetManager.close = function(id) { + prevClose.call(WidgetManager, id); + // Pruefen ob es ein Image-Widget ist + const isImage = self._images.some(img => img.id === id); + if (isImage) { + self.onClose(id); + } + }; + + // Minimize-Event abfangen + const prevMinimize = WidgetManager.minimize; + WidgetManager.minimize = async function(id) { + await prevMinimize.call(WidgetManager, id); + const isImage = self._images.some(img => img.id === id); + if (isImage) { + await self.save(); + } + }; + + // Open-Event abfangen + const prevOpen = WidgetManager.openWidget; + WidgetManager.openWidget = async function(id) { + await prevOpen.call(WidgetManager, id); + const imgData = self._images.find(img => img.id === id); + if (imgData) { + const body = WidgetManager.getBody(id); + if (body && body.children.length === 0) { + const dataUrl = self._getSessionImage(id); + self.renderBody(imgData, body, dataUrl); + } + await self.save(); + } + }; + } +}; diff --git a/src/js/notes.js b/src/js/notes.js index 5f44caf..d0b7f13 100644 --- a/src/js/notes.js +++ b/src/js/notes.js @@ -53,6 +53,9 @@ const Notes = { if (existing && existing.timer) { saveData.timer = existing.timer; } + if (existing && existing.imageRef) { + saveData.imageRef = existing.imageRef; + } await Store.set(this.STORAGE_KEY, saveData); }, @@ -527,6 +530,8 @@ const Notes = { Calculator.toggle(); } else if (action === 'timer') { Timer.toggle(); + } else if (action === 'image-ref') { + ImageRef.create(); } else if (action === 'notebook') { this.openNotebook(); } diff --git a/src/js/settings.js b/src/js/settings.js index 7f9bc95..f8d12ca 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -71,6 +71,13 @@ function applySettings() { const showSearchEl = document.getElementById('settingShowSearch'); if (showSearchEl) showSearchEl.checked = settings.showSearch; + // Image-Ref Toggle + if (settings.imageRefEnabled === undefined) settings.imageRefEnabled = false; + const imgRefCheckbox = document.getElementById('settingImageRef'); + if (imgRefCheckbox) imgRefCheckbox.checked = settings.imageRefEnabled; + const imgRefBtn = document.querySelector('[data-action="image-ref"]'); + if (imgRefBtn) imgRefBtn.classList.toggle('hidden', !settings.imageRefEnabled); + // Toolbar-Position document.body.classList.toggle('toolbar-left', settings.toolbarPos === 'left'); const toolbarPosEl = document.getElementById('settingToolbarPos'); @@ -127,6 +134,11 @@ function bindSettingsEvents() { settingShowSearch: v => { settings.showSearch = v; document.getElementById('searchBarWrapper').classList.toggle('hidden', !v); + }, + settingImageRef: v => { + settings.imageRefEnabled = v; + const imgBtn = document.querySelector('[data-action="image-ref"]'); + if (imgBtn) imgBtn.classList.toggle('hidden', !v); } }; @@ -204,7 +216,8 @@ function bindSettingsEvents() { boards = []; settings = { compact: false, shortenTitles: false, newTab: true, showDesc: false, hideExtra: false, visibleCount: 10, bgUrl: '', theme: 'nebula', - showSearch: true, searchEngine: 'google', toolbarPos: 'right' }; + showSearch: true, searchEngine: 'google', toolbarPos: 'right', + imageRefEnabled: false }; await saveBoards(); await saveSettings(); applySettings(); diff --git a/src/js/state.js b/src/js/state.js index 8ceff72..9696616 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -16,7 +16,8 @@ let settings = { theme: 'nebula', showSearch: true, searchEngine: 'google', - toolbarPos: 'right' + toolbarPos: 'right', + imageRefEnabled: false }; function uid() {