feat(widgets): Widget-System mit Notes, Checklisten und Notebook-Sidebar
Neues modulares Widget-System als Ersatz für die alte Sticky Note. Widget-Manager (Drag, Resize, Z-Index, Persistierung), Freitext-Notes mit Zeichenzähler, Checklisten mit Toggle/Add/Remove, Notebook-Sidebar mit 5 Slots, Widget-Toolbar am rechten Rand.
This commit is contained in:
+555
@@ -0,0 +1,555 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — notes.js
|
||||
Notes: Freitext, Checklisten, Notebook-Sidebar
|
||||
============================================= */
|
||||
|
||||
const Notes = {
|
||||
MAX_NOTES: 5,
|
||||
MAX_CHARS: 2500,
|
||||
STORAGE_KEY: 'widgetStates',
|
||||
/** @type {Array<Object>} */
|
||||
_notes: [],
|
||||
_saveTimer: null,
|
||||
|
||||
/**
|
||||
* Notes aus Storage laden
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async load() {
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (data && Array.isArray(data.notes)) {
|
||||
this._notes = data.notes;
|
||||
}
|
||||
return this._notes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle Notes in Storage speichern
|
||||
*/
|
||||
async save() {
|
||||
// Widget-States mit Note-Daten mergen
|
||||
const widgetStates = WidgetManager.save ? await WidgetManager.save() : [];
|
||||
|
||||
// Note-Daten mit aktuellen Widget-Positionen mergen
|
||||
const merged = this._notes.map(note => {
|
||||
const ws = widgetStates.find(w => w.id === note.id);
|
||||
if (ws) {
|
||||
note.x = ws.x;
|
||||
note.y = ws.y;
|
||||
note.width = ws.width;
|
||||
note.height = ws.height;
|
||||
note.open = ws.open;
|
||||
note.title = ws.title;
|
||||
}
|
||||
return note;
|
||||
});
|
||||
|
||||
await Store.set(this.STORAGE_KEY, { notes: merged });
|
||||
},
|
||||
|
||||
/**
|
||||
* Debounced Save (fuer Auto-Save bei Input)
|
||||
*/
|
||||
_debouncedSave() {
|
||||
clearTimeout(this._saveTimer);
|
||||
this._saveTimer = setTimeout(() => this.save(), 500);
|
||||
},
|
||||
|
||||
/**
|
||||
* Neue Note erstellen
|
||||
* @param {'text'|'checklist'} template
|
||||
* @returns {Promise<string|null>} widget-id oder null bei vollem Limit
|
||||
*/
|
||||
async create(template) {
|
||||
if (this._notes.length >= this.MAX_NOTES) {
|
||||
await HellionDialog.alert(
|
||||
'Maximale Anzahl erreicht! Du kannst maximal ' + this.MAX_NOTES + ' Notes gleichzeitig haben. Lösche eine bestehende Note um eine neue zu erstellen.',
|
||||
{ type: 'warning', title: 'Limit erreicht' }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const noteData = {
|
||||
id: 'note_' + uid(),
|
||||
title: template === 'checklist' ? 'Checkliste' : 'Note',
|
||||
content: '',
|
||||
template: template,
|
||||
x: 120 + (this._notes.length * 30),
|
||||
y: 80 + (this._notes.length * 30),
|
||||
width: 280,
|
||||
height: 220,
|
||||
open: true,
|
||||
checkedItems: [],
|
||||
checklistItems: []
|
||||
};
|
||||
|
||||
this._notes.push(noteData);
|
||||
|
||||
// Widget erstellen
|
||||
const widgetId = WidgetManager.create('note', {
|
||||
id: noteData.id,
|
||||
title: noteData.title,
|
||||
x: noteData.x,
|
||||
y: noteData.y,
|
||||
width: noteData.width,
|
||||
height: noteData.height,
|
||||
open: true
|
||||
});
|
||||
|
||||
// Body rendern
|
||||
const body = WidgetManager.getBody(widgetId);
|
||||
if (body) this.renderBody(noteData, body);
|
||||
|
||||
await this.save();
|
||||
return widgetId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Note-Body rendern (in Widget-Body einfuegen)
|
||||
* @param {Object} noteData
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
renderBody(noteData, bodyEl) {
|
||||
bodyEl.textContent = '';
|
||||
if (noteData.template === 'checklist') {
|
||||
this._renderChecklistBody(noteData, bodyEl);
|
||||
} else {
|
||||
this._renderTextBody(noteData, bodyEl);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Freitext-Body: Textarea mit Zeichenzaehler
|
||||
* @param {Object} noteData
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
_renderTextBody(noteData, bodyEl) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'widget-textarea';
|
||||
textarea.placeholder = 'Notiz schreiben...';
|
||||
textarea.spellcheck = false;
|
||||
textarea.value = noteData.content || '';
|
||||
textarea.maxLength = this.MAX_CHARS;
|
||||
|
||||
const counter = document.createElement('span');
|
||||
counter.className = 'widget-char-count';
|
||||
counter.textContent = (noteData.content || '').length + ' / ' + this.MAX_CHARS;
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
noteData.content = textarea.value;
|
||||
const len = textarea.value.length;
|
||||
counter.textContent = len + ' / ' + this.MAX_CHARS;
|
||||
counter.classList.toggle('limit', len >= this.MAX_CHARS);
|
||||
|
||||
// Auto-Titel aus erster Zeile
|
||||
const firstLine = textarea.value.split('\n')[0].trim().slice(0, 20);
|
||||
if (firstLine) {
|
||||
noteData.title = firstLine;
|
||||
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||
if (widgetEntry) {
|
||||
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||
titleEl.textContent = firstLine;
|
||||
}
|
||||
widgetEntry.state.title = firstLine;
|
||||
}
|
||||
}
|
||||
|
||||
this._debouncedSave();
|
||||
});
|
||||
|
||||
bodyEl.append(textarea, counter);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checklisten-Body: Items mit Checkboxen
|
||||
* @param {Object} noteData
|
||||
* @param {HTMLElement} bodyEl
|
||||
*/
|
||||
_renderChecklistBody(noteData, bodyEl) {
|
||||
const list = document.createElement('ul');
|
||||
list.className = 'widget-checklist';
|
||||
|
||||
// Bestehende Items rendern
|
||||
if (!Array.isArray(noteData.checklistItems)) {
|
||||
noteData.checklistItems = [];
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
list.textContent = '';
|
||||
noteData.checklistItems.forEach((item, idx) => {
|
||||
const li = this._createChecklistItem(noteData, item, idx, renderItems);
|
||||
list.appendChild(li);
|
||||
});
|
||||
};
|
||||
|
||||
renderItems();
|
||||
|
||||
// Eingabefeld fuer neue Items
|
||||
const addRow = document.createElement('div');
|
||||
addRow.className = 'checklist-add';
|
||||
|
||||
const addInput = document.createElement('input');
|
||||
addInput.className = 'checklist-add-input';
|
||||
addInput.type = 'text';
|
||||
addInput.placeholder = 'Neues Item...';
|
||||
addInput.maxLength = 100;
|
||||
|
||||
addInput.addEventListener('keydown', async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const text = addInput.value.trim();
|
||||
if (!text) return;
|
||||
noteData.checklistItems.push({ text, checked: false });
|
||||
addInput.value = '';
|
||||
renderItems();
|
||||
this._updateChecklistContent(noteData);
|
||||
await this.save();
|
||||
}
|
||||
});
|
||||
|
||||
addRow.appendChild(addInput);
|
||||
bodyEl.append(list, addRow);
|
||||
},
|
||||
|
||||
/**
|
||||
* Einzelnes Checklisten-Item erstellen
|
||||
* @param {Object} noteData
|
||||
* @param {Object} item - { text, checked }
|
||||
* @param {number} idx
|
||||
* @param {Function} rerenderFn
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createChecklistItem(noteData, item, idx, rerenderFn) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'checklist-item' + (item.checked ? ' checked' : '');
|
||||
|
||||
const checkbox = document.createElement('span');
|
||||
checkbox.className = 'checklist-checkbox';
|
||||
checkbox.textContent = item.checked ? '\u2713' : '';
|
||||
checkbox.addEventListener('click', async () => {
|
||||
item.checked = !item.checked;
|
||||
li.classList.toggle('checked', item.checked);
|
||||
checkbox.textContent = item.checked ? '\u2713' : '';
|
||||
this._updateChecklistContent(noteData);
|
||||
await this.save();
|
||||
});
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'checklist-text';
|
||||
text.textContent = item.text;
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'checklist-remove';
|
||||
removeBtn.textContent = '\u2715';
|
||||
removeBtn.addEventListener('click', async () => {
|
||||
noteData.checklistItems.splice(idx, 1);
|
||||
rerenderFn();
|
||||
this._updateChecklistContent(noteData);
|
||||
await this.save();
|
||||
});
|
||||
|
||||
li.append(checkbox, text, removeBtn);
|
||||
return li;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checklisten-Content fuer Export/Vorschau aktualisieren
|
||||
* @param {Object} noteData
|
||||
*/
|
||||
_updateChecklistContent(noteData) {
|
||||
const total = noteData.checklistItems.length;
|
||||
const done = noteData.checklistItems.filter(i => i.checked).length;
|
||||
noteData.content = noteData.checklistItems.map(i => (i.checked ? '[x] ' : '[ ] ') + i.text).join('\n');
|
||||
|
||||
// Auto-Titel: "X/Y erledigt" falls kein manueller Titel
|
||||
const widgetEntry = WidgetManager._widgets.get(noteData.id);
|
||||
if (widgetEntry) {
|
||||
const defaultTitle = done + '/' + total + ' erledigt';
|
||||
const titleEl = widgetEntry.el.querySelector('.widget-title');
|
||||
if (titleEl && titleEl.contentEditable !== 'true') {
|
||||
// Nur wenn Titel noch Standard ist
|
||||
if (noteData.title === 'Checkliste' || /^\d+\/\d+ erledigt$/.test(noteData.title)) {
|
||||
noteData.title = defaultTitle;
|
||||
titleEl.textContent = defaultTitle;
|
||||
widgetEntry.state.title = defaultTitle;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Note anhand ID finden
|
||||
* @param {string} id
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getNote(id) {
|
||||
return this._notes.find(n => n.id === id) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Note loeschen
|
||||
* @param {string} id
|
||||
*/
|
||||
async deleteNote(id) {
|
||||
const idx = this._notes.findIndex(n => n.id === id);
|
||||
if (idx === -1) return;
|
||||
|
||||
const ok = await HellionDialog.confirm(
|
||||
'Note endgültig löschen? Das kann nicht rückgängig gemacht werden.',
|
||||
{ type: 'danger', title: 'Note löschen', confirmText: 'Löschen' }
|
||||
);
|
||||
if (!ok) return;
|
||||
|
||||
this._notes.splice(idx, 1);
|
||||
WidgetManager.close(id);
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Note als .md exportieren
|
||||
* @param {Object} noteData
|
||||
*/
|
||||
exportNote(noteData) {
|
||||
let md = '# ' + noteData.title + '\n\n';
|
||||
if (noteData.template === 'checklist') {
|
||||
noteData.checklistItems.forEach(item => {
|
||||
md += (item.checked ? '- [x] ' : '- [ ] ') + item.text + '\n';
|
||||
});
|
||||
} else {
|
||||
md += noteData.content || '';
|
||||
}
|
||||
md += '\n\n---\n*Exportiert aus Hellion Dashboard*\n';
|
||||
|
||||
const blob = new Blob([md], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = (noteData.title || 'note').replace(/[^a-zA-Z0-9äöüÄÖÜß_-]/g, '_') + '.md';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// ---- NOTEBOOK SIDEBAR ----
|
||||
|
||||
/**
|
||||
* Notebook-Sidebar oeffnen
|
||||
*/
|
||||
openNotebook() {
|
||||
const overlay = document.getElementById('notebookOverlay');
|
||||
const panel = document.getElementById('notebookPanel');
|
||||
if (overlay) overlay.classList.add('active');
|
||||
if (panel) panel.classList.add('open');
|
||||
this._renderNotebookSlots();
|
||||
},
|
||||
|
||||
/**
|
||||
* Notebook-Sidebar schliessen
|
||||
*/
|
||||
closeNotebook() {
|
||||
const overlay = document.getElementById('notebookOverlay');
|
||||
const panel = document.getElementById('notebookPanel');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
if (panel) panel.classList.remove('open');
|
||||
},
|
||||
|
||||
/**
|
||||
* Notebook-Slots rendern
|
||||
*/
|
||||
_renderNotebookSlots() {
|
||||
const container = document.getElementById('notebookSlots');
|
||||
const countEl = document.getElementById('notebookCount');
|
||||
if (!container) return;
|
||||
|
||||
container.textContent = '';
|
||||
if (countEl) countEl.textContent = this._notes.length + ' / ' + this.MAX_NOTES;
|
||||
|
||||
// Belegte Slots
|
||||
this._notes.forEach(note => {
|
||||
const slot = this._createNotebookSlot(note);
|
||||
container.appendChild(slot);
|
||||
});
|
||||
|
||||
// Leere Slots
|
||||
const remaining = this.MAX_NOTES - this._notes.length;
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
const emptySlot = this._createEmptySlot();
|
||||
container.appendChild(emptySlot);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Belegten Notebook-Slot erstellen
|
||||
* @param {Object} note
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createNotebookSlot(note) {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'notebook-slot';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'notebook-slot-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'notebook-slot-title';
|
||||
|
||||
const typeIcon = document.createElement('span');
|
||||
typeIcon.className = 'notebook-slot-type';
|
||||
typeIcon.textContent = note.template === 'checklist' ? '\u2611' : '\u270E';
|
||||
title.append(typeIcon);
|
||||
title.append(document.createTextNode(' ' + note.title));
|
||||
|
||||
header.appendChild(title);
|
||||
|
||||
// Preview
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'notebook-slot-preview';
|
||||
if (note.template === 'checklist') {
|
||||
const total = note.checklistItems ? note.checklistItems.length : 0;
|
||||
const done = note.checklistItems ? note.checklistItems.filter(i => i.checked).length : 0;
|
||||
preview.textContent = done + '/' + total + ' erledigt';
|
||||
} else {
|
||||
preview.textContent = (note.content || '').slice(0, 50) || 'Leer';
|
||||
}
|
||||
|
||||
// Actions
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'notebook-slot-actions';
|
||||
|
||||
const btnExport = document.createElement('button');
|
||||
btnExport.className = 'notebook-slot-btn';
|
||||
btnExport.textContent = 'Export';
|
||||
btnExport.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.exportNote(note);
|
||||
});
|
||||
|
||||
const btnDelete = document.createElement('button');
|
||||
btnDelete.className = 'notebook-slot-btn danger';
|
||||
btnDelete.textContent = '\uD83D\uDDD1';
|
||||
btnDelete.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.deleteNote(note.id);
|
||||
this._renderNotebookSlots();
|
||||
});
|
||||
|
||||
actions.append(btnExport, btnDelete);
|
||||
slot.append(header, preview, actions);
|
||||
|
||||
// Klick oeffnet Note als Widget
|
||||
slot.addEventListener('click', async () => {
|
||||
if (WidgetManager.isOpen(note.id)) {
|
||||
WidgetManager.bringToFront(note.id);
|
||||
} else {
|
||||
await WidgetManager.openWidget(note.id);
|
||||
}
|
||||
this.closeNotebook();
|
||||
});
|
||||
|
||||
return slot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Leeren Notebook-Slot erstellen
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_createEmptySlot() {
|
||||
const slot = document.createElement('div');
|
||||
slot.className = 'notebook-slot-empty';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = '+ Note erstellen';
|
||||
slot.appendChild(label);
|
||||
|
||||
// Klick zeigt Typ-Auswahl
|
||||
let chooserOpen = false;
|
||||
slot.addEventListener('click', () => {
|
||||
if (chooserOpen) return;
|
||||
chooserOpen = true;
|
||||
label.style.display = 'none';
|
||||
|
||||
const chooser = document.createElement('div');
|
||||
chooser.className = 'notebook-type-chooser';
|
||||
|
||||
const btnText = document.createElement('button');
|
||||
btnText.className = 'notebook-type-btn';
|
||||
btnText.textContent = '\u270E Freitext';
|
||||
btnText.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.create('text');
|
||||
this._renderNotebookSlots();
|
||||
});
|
||||
|
||||
const btnCheck = document.createElement('button');
|
||||
btnCheck.className = 'notebook-type-btn';
|
||||
btnCheck.textContent = '\u2611 Checkliste';
|
||||
btnCheck.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await this.create('checklist');
|
||||
this._renderNotebookSlots();
|
||||
});
|
||||
|
||||
chooser.append(btnText, btnCheck);
|
||||
slot.appendChild(chooser);
|
||||
});
|
||||
|
||||
return slot;
|
||||
},
|
||||
|
||||
// ---- TOOLBAR EVENTS ----
|
||||
|
||||
/**
|
||||
* Widget-Toolbar initialisieren
|
||||
*/
|
||||
initToolbar() {
|
||||
const toolbar = document.getElementById('widgetToolbar');
|
||||
if (!toolbar) return;
|
||||
|
||||
toolbar.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.widget-toolbar-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const action = btn.dataset.action;
|
||||
if (action === 'new-note') {
|
||||
await this.create('text');
|
||||
} else if (action === 'new-checklist') {
|
||||
await this.create('checklist');
|
||||
} else if (action === 'notebook') {
|
||||
this.openNotebook();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// ---- INIT ----
|
||||
|
||||
/**
|
||||
* Notes-System initialisieren (ersetzt initStickyNote)
|
||||
*/
|
||||
async init() {
|
||||
await this.load();
|
||||
|
||||
// Widgets wiederherstellen
|
||||
await WidgetManager.restore((noteData, bodyEl) => {
|
||||
this.renderBody(noteData, bodyEl);
|
||||
});
|
||||
|
||||
// Toolbar initialisieren
|
||||
this.initToolbar();
|
||||
|
||||
// Notebook-Sidebar Events
|
||||
const notebookOverlay = document.getElementById('notebookOverlay');
|
||||
if (notebookOverlay) {
|
||||
notebookOverlay.addEventListener('click', () => this.closeNotebook());
|
||||
}
|
||||
const btnCloseNotebook = document.getElementById('btnCloseNotebook');
|
||||
if (btnCloseNotebook) {
|
||||
btnCloseNotebook.addEventListener('click', () => this.closeNotebook());
|
||||
}
|
||||
|
||||
// Header btnNote oeffnet Notebook
|
||||
const btnNote = document.getElementById('btnNote');
|
||||
if (btnNote) {
|
||||
btnNote.addEventListener('click', () => this.openNotebook());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,365 @@
|
||||
/* =============================================
|
||||
HELLION NEWTAB — widgets.js
|
||||
Widget-Manager: Registry, Drag, Resize, Z-Index, Persistierung
|
||||
============================================= */
|
||||
|
||||
const WidgetManager = {
|
||||
/** @type {Map<string, {el: HTMLElement, type: string, state: Object}>} */
|
||||
_widgets: new Map(),
|
||||
_topZ: 51,
|
||||
STORAGE_KEY: 'widgetStates',
|
||||
|
||||
/**
|
||||
* Widget erstellen und in DOM einfuegen
|
||||
* @param {string} type - 'note'
|
||||
* @param {Object} config - { id, title, x, y, width, height, open }
|
||||
* @returns {string} widget-id
|
||||
*/
|
||||
create(type, config) {
|
||||
const id = config.id || ('widget_' + uid());
|
||||
const state = {
|
||||
id,
|
||||
type,
|
||||
title: config.title || 'Note',
|
||||
x: config.x || 120,
|
||||
y: config.y || 80,
|
||||
width: config.width || 280,
|
||||
height: config.height || 220,
|
||||
open: config.open !== false
|
||||
};
|
||||
|
||||
const el = this._buildDOM(state);
|
||||
document.body.appendChild(el);
|
||||
|
||||
this._widgets.set(id, { el, type, state });
|
||||
this._initDrag(el);
|
||||
this._initResize(el);
|
||||
this.bringToFront(id);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget-DOM erzeugen (createElement, kein innerHTML)
|
||||
* @param {Object} state
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
_buildDOM(state) {
|
||||
const widget = document.createElement('div');
|
||||
widget.className = 'widget';
|
||||
widget.dataset.widgetId = state.id;
|
||||
widget.style.left = state.x + 'px';
|
||||
widget.style.top = state.y + 'px';
|
||||
widget.style.width = state.width + 'px';
|
||||
widget.style.height = state.height + 'px';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'widget-header';
|
||||
|
||||
const title = document.createElement('span');
|
||||
title.className = 'widget-title';
|
||||
title.textContent = state.title;
|
||||
|
||||
// Doppelklick auf Titel zum Editieren
|
||||
title.addEventListener('dblclick', () => {
|
||||
title.contentEditable = 'true';
|
||||
title.focus();
|
||||
// Text selektieren
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(title);
|
||||
const sel = window.getSelection();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
});
|
||||
title.addEventListener('blur', async () => {
|
||||
title.contentEditable = 'false';
|
||||
const newTitle = title.textContent.trim().slice(0, 20);
|
||||
title.textContent = newTitle || 'Note';
|
||||
const entry = this._widgets.get(state.id);
|
||||
if (entry) {
|
||||
entry.state.title = title.textContent;
|
||||
await this.save();
|
||||
}
|
||||
});
|
||||
title.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
title.blur();
|
||||
}
|
||||
});
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'widget-actions';
|
||||
|
||||
const btnMin = document.createElement('button');
|
||||
btnMin.className = 'widget-btn widget-minimize';
|
||||
btnMin.title = 'Minimieren';
|
||||
btnMin.textContent = '\u2500';
|
||||
btnMin.addEventListener('click', () => this.minimize(state.id));
|
||||
|
||||
const btnClose = document.createElement('button');
|
||||
btnClose.className = 'widget-btn widget-close';
|
||||
btnClose.title = 'Schließen';
|
||||
btnClose.textContent = '\u2715';
|
||||
btnClose.addEventListener('click', () => this.close(state.id));
|
||||
|
||||
actions.append(btnMin, btnClose);
|
||||
header.append(title, actions);
|
||||
|
||||
// Body
|
||||
const body = document.createElement('div');
|
||||
body.className = 'widget-body';
|
||||
|
||||
// Resize Handle
|
||||
const resizeHandle = document.createElement('div');
|
||||
resizeHandle.className = 'widget-resize-handle';
|
||||
|
||||
widget.append(header, body, resizeHandle);
|
||||
|
||||
// Klick auf Widget bringt es nach vorne
|
||||
widget.addEventListener('pointerdown', () => {
|
||||
this.bringToFront(state.id);
|
||||
});
|
||||
|
||||
return widget;
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget-Body-Element holen
|
||||
* @param {string} id
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
getBody(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return null;
|
||||
return entry.el.querySelector('.widget-body');
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget entfernen (endgueltig loeschen)
|
||||
* @param {string} id
|
||||
*/
|
||||
close(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.el.remove();
|
||||
this._widgets.delete(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget minimieren (aus DOM verstecken, bleibt im Notebook)
|
||||
* @param {string} id
|
||||
*/
|
||||
async minimize(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = false;
|
||||
entry.el.classList.add('widget-minimized');
|
||||
setTimeout(() => {
|
||||
entry.el.style.display = 'none';
|
||||
}, 250);
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget oeffnen (aus minimiertem Zustand wiederherstellen)
|
||||
* @param {string} id
|
||||
*/
|
||||
async openWidget(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
entry.state.open = true;
|
||||
entry.el.style.display = 'flex';
|
||||
// Naechster Frame fuer Animation
|
||||
requestAnimationFrame(() => {
|
||||
entry.el.classList.remove('widget-minimized');
|
||||
});
|
||||
this.bringToFront(id);
|
||||
await this.save();
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget in den Vordergrund bringen
|
||||
* @param {string} id
|
||||
*/
|
||||
bringToFront(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (!entry) return;
|
||||
this._topZ++;
|
||||
entry.el.style.zIndex = this._topZ;
|
||||
},
|
||||
|
||||
/**
|
||||
* Drag initialisieren (Pointer Events auf Header)
|
||||
* @param {HTMLElement} widgetEl
|
||||
*/
|
||||
_initDrag(widgetEl) {
|
||||
const header = widgetEl.querySelector('.widget-header');
|
||||
const self = this;
|
||||
|
||||
header.addEventListener('pointerdown', function onDown(e) {
|
||||
if (e.target.closest('.widget-btn') || e.target.closest('.widget-title[contenteditable="true"]')) return;
|
||||
e.preventDefault();
|
||||
header.setPointerCapture(e.pointerId);
|
||||
|
||||
const rect = widgetEl.getBoundingClientRect();
|
||||
const offX = e.clientX - rect.left;
|
||||
const offY = e.clientY - rect.top;
|
||||
|
||||
function onMove(ev) {
|
||||
const maxX = window.innerWidth - widgetEl.offsetWidth;
|
||||
const maxY = window.innerHeight - widgetEl.offsetHeight;
|
||||
widgetEl.style.left = Math.max(0, Math.min(maxX, ev.clientX - offX)) + 'px';
|
||||
widgetEl.style.top = Math.max(48, Math.min(maxY, ev.clientY - offY)) + 'px';
|
||||
}
|
||||
|
||||
async function onUp() {
|
||||
header.releasePointerCapture(e.pointerId);
|
||||
header.removeEventListener('pointermove', onMove);
|
||||
header.removeEventListener('pointerup', onUp);
|
||||
// State aktualisieren
|
||||
const id = widgetEl.dataset.widgetId;
|
||||
const entry = self._widgets.get(id);
|
||||
if (entry) {
|
||||
entry.state.x = parseFloat(widgetEl.style.left);
|
||||
entry.state.y = parseFloat(widgetEl.style.top);
|
||||
await self.save();
|
||||
}
|
||||
}
|
||||
|
||||
header.addEventListener('pointermove', onMove);
|
||||
header.addEventListener('pointerup', onUp);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Resize initialisieren (Pointer Events auf Handle)
|
||||
* @param {HTMLElement} widgetEl
|
||||
*/
|
||||
_initResize(widgetEl) {
|
||||
const handle = widgetEl.querySelector('.widget-resize-handle');
|
||||
const self = this;
|
||||
|
||||
handle.addEventListener('pointerdown', function onDown(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
|
||||
const startW = widgetEl.offsetWidth;
|
||||
const startH = widgetEl.offsetHeight;
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
|
||||
function onMove(ev) {
|
||||
widgetEl.style.width = Math.max(200, startW + (ev.clientX - startX)) + 'px';
|
||||
widgetEl.style.height = Math.max(150, startH + (ev.clientY - startY)) + 'px';
|
||||
}
|
||||
|
||||
async function onUp() {
|
||||
handle.releasePointerCapture(e.pointerId);
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
handle.removeEventListener('pointerup', onUp);
|
||||
const id = widgetEl.dataset.widgetId;
|
||||
const entry = self._widgets.get(id);
|
||||
if (entry) {
|
||||
entry.state.width = widgetEl.offsetWidth;
|
||||
entry.state.height = widgetEl.offsetHeight;
|
||||
await self.save();
|
||||
}
|
||||
}
|
||||
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', onUp);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle Widget-States aus Storage laden und wiederherstellen
|
||||
* @param {Function} renderCallback - Funktion die den Body rendert (noteData, bodyEl)
|
||||
*/
|
||||
async restore(renderCallback) {
|
||||
const data = await Store.get(this.STORAGE_KEY);
|
||||
if (!data || !Array.isArray(data.notes)) return;
|
||||
|
||||
for (const noteData of data.notes) {
|
||||
const id = this.create('note', {
|
||||
id: noteData.id,
|
||||
title: noteData.title,
|
||||
x: noteData.x,
|
||||
y: noteData.y,
|
||||
width: noteData.width,
|
||||
height: noteData.height,
|
||||
open: noteData.open
|
||||
});
|
||||
|
||||
// Body rendern lassen (von Notes-Modul)
|
||||
if (renderCallback) {
|
||||
const body = this.getBody(id);
|
||||
if (body) renderCallback(noteData, body);
|
||||
}
|
||||
|
||||
// Falls minimiert, sofort verstecken
|
||||
if (!noteData.open) {
|
||||
const entry = this._widgets.get(id);
|
||||
if (entry) {
|
||||
entry.el.classList.add('widget-minimized');
|
||||
entry.el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle Widget-States speichern
|
||||
*/
|
||||
async save() {
|
||||
const notes = [];
|
||||
for (const [id, entry] of this._widgets) {
|
||||
if (entry.type === 'note') {
|
||||
notes.push({
|
||||
...entry.state,
|
||||
// Zusaetzliche Note-Daten werden von Notes.save() ergaenzt
|
||||
});
|
||||
}
|
||||
}
|
||||
// Nicht direkt speichern — Notes-Modul merged die Daten
|
||||
return notes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Widget-State fuer eine bestimmte ID holen
|
||||
* @param {string} id
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getState(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
return entry ? entry.state : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pruefen ob Widget offen ist
|
||||
* @param {string} id
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOpen(id) {
|
||||
const entry = this._widgets.get(id);
|
||||
return entry ? entry.state.open : false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Anzahl aller Widgets
|
||||
* @returns {number}
|
||||
*/
|
||||
count() {
|
||||
return this._widgets.size;
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle Widget-IDs
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getAllIds() {
|
||||
return Array.from(this._widgets.keys());
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user