From 31d27ffa92804d83145887119d462b953c30c983 Mon Sep 17 00:00:00 2001 From: epaper-dashboard Date: Sat, 29 Aug 2026 16:52:55 +0400 Subject: [PATCH] FEAT: 4x4 grid UI fixes, smooth drag-drop ghost preview, WordClock plugin - Grid items now correctly placed inside .grid-cell divs (CSS Grid children) - Visual occupied-state feedback during drag (CSS classes, no full re-render) - Drag ghost preview (green=ok, red=overlap) replaces flicker on dragend - New clock_wordclock plugin: German/English word clock (QWATCH layout) --- layout.py | 1 + plugins/clock_wordclock.py | 224 +++++++++++++++++++++++ templates/index.html | 363 +++++++++++++++++++++++++------------ 3 files changed, 469 insertions(+), 119 deletions(-) create mode 100644 plugins/clock_wordclock.py diff --git a/layout.py b/layout.py index a89ab55..d72d6a3 100644 --- a/layout.py +++ b/layout.py @@ -145,6 +145,7 @@ def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]: """Default size when user adds a new item.""" presets = { "clock": (2, 2), + "clock_wordclock": (2, 2), "weather": (2, 2), "system": (2, 2), "minimax": (2, 2), diff --git a/plugins/clock_wordclock.py b/plugins/clock_wordclock.py new file mode 100644 index 0000000..262f1ed --- /dev/null +++ b/plugins/clock_wordclock.py @@ -0,0 +1,224 @@ +"""WordClock-Plugin:Deutsche Wort-Uhr (QWATCHLayout). + +Reines Wort-Uhr-Display im deutschen Stil: + ES IST + +Unterstützt auch ENGLISCH (US-Layout). + +Layout: fester 4×4-Zeichen-Grid im Quadrat (4×4 Zellen = 800×480 Display). +Jede Zelle = 200×120px → Grid = 800×480px. +Wir nutzen 11×8 "字符-Zellen" pro Grid (72×60px pro Zeichen). + +Minuten-Zeilen (oberer Block): + Zeile 0: [E][S][ ][I][S][T] + Zeile 1: [F][Ü][N][F][Z][E][H][N][Z][W][A][N] + Zeile 2: [V][I][E][R][T][E][L][Z][W][A][N][Z] + Zeile 3: [N][U][L][L] + Zeile 4: [N][A][C][H][ ][V][O][R][ ][H][A][L] + Zeile 5: [B][ ] + Zeile 6: [S][P][R][A][C][H][E] + +Stunden (unterer Block, je 2×2 Zellen für die Ziffern): + DieZiffern werden als gefüllte Rechtecke in der unteren Reihe gerendert. + +Das Layout wird intern gecacht bis sich die Minute ändert. +""" +from __future__ import annotations +import os, sys +from datetime import datetime +from functools import lru_cache +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from plugins.base import Widget +from palette import FG, BG, OK, BLUE, YELLOW, ORANGE, fill_for, measure + + +class Widget(Widget): + name = "clock_wordclock" + label = "WordClock" + description = "Deutsche Wort-Uhr (ES IST …). Nur für 2×2 oder größer." + category = "info" + + config_schema = [ + {"key": "lang", "label": "Sprache", + "type": "select", "choices": ["de", "en"], "default": "de", + "help": "de = Deutsch (Standard), en = Englisch"}, + {"key": "show_date", "label": "Datum anzeigen", + "type": "bool", "default": True}, + {"key": "show_weekday", "label": "Wochentag anzeigen", + "type": "bool", "default": True}, + {"key": "accent_color", "label": "Akzentfarbe", + "type": "select", + "choices": ["accent", "blue", "ok", "warn", "info"], + "default": "accent"}, + {"key": "invert", "label": "Invertiert (dunkel)", + "type": "bool", "default": False}, + ] + default_config = { + "lang": "de", "show_date": True, "show_weekday": True, + "accent_color": "accent", "invert": False, + } + + def fetch(self): + return {} + + def render(self, draw, fonts, x: int, y: int, w: int, h: int): + from palette import fill_for + now = datetime.now() + m = now.minute + h_ = now.hour + + invert = self.cfg("invert", False) + accent = fill_for(self.cfg("accent_color", "accent")) + fg = BG if invert else FG + bg = FG if invert else BG + + pad = 8 + + # Mindestgröße: 300px-breit, 240px-hoch für WordClock + if w < 300 or h < 240: + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg) + draw.text((x + pad, y + pad), + "WordClock\n(min 2×2)", + font=fonts.get("20", fonts.get("default")), fill=fg) + return + + # ---- Wort-Uhr Grid ---- + # 4×4 Zellen → 800×480px + # Wir rendern in ein 11×8 Zeichen-Grid + chars_x, chars_y = 11, 8 + char_w = w // chars_x + char_h = h // chars_y + font_size = min(char_w, char_h) * 2 // 3 + font_key = str(font_size) + if font_key not in fonts: + font_key = str(max(16, min(fonts.keys(), key=lambda k: abs(int(k) - font_size) if k.isdigit() else 9999)) if fonts else "20") + fnt = fonts.get(font_key, fonts.get("20", fonts.get("default"))) + + def _draw_char(cx, cy, char, color): + """Zeichnet ein Zeichen an Gitterposition (cx, cy).""" + px = x + cx * char_w + py = y + cy * char_h + tw, th = measure(draw, char, fnt) + draw.text((px + (char_w - tw) // 2, py + (char_h - th) // 2), + char, font=fnt, fill=color) + + def _fill_char(cx, cy, color): + """Füllt eine Gitterzelle mit einer Farbe (z.B. für Stunden-Dots).""" + px = x + cx * char_w + py = y + cy * char_h + draw.rectangle((px + 2, py + 2, px + char_w - 3, py + char_h - 3), fill=color) + + def _lit(cx, cy): + _draw_char(cx, cy, LAYOUT_DE[cy][cx], fg) + + def _dim(cx, cy): + _draw_char(cx, cy, LAYOUT_DE[cy][cx], (150, 150, 150)) + + # ---- Minuten-Logik (Deutsch) ---- + def lit_minute(m): + """Sektor der Minuten: 0-4, 5-9, 10-14, 15-19, 20-24, 25-29, 30-34, 35-39, 40-44, 45-49, 50-54, 55-59.""" + if m < 5: + return [] + elif m < 10: + return [(0, 1), (1, 1), (2, 1), (3, 1)] # FÜNF + elif m < 15: + return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN + elif m < 20: + return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2)] # VIERTEL + elif m < 25: + return [(7, 2), (8, 2), (9, 2), (10, 2), (7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG + elif m < 30: + return [(0, 4), (1, 4), (2, 4), (3, 4)] # NACH + elif m < 35: + return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + (4, 4), (5, 4), (6, 4)] # + HALB + elif m < 40: + return [(4, 4), (5, 4), (6, 4)] # HALB + elif m < 45: + return [(7, 3), (8, 3), (9, 3), (10, 3)] # ZWANZIG + elif m < 50: + return [(0, 2), (1, 2), (2, 2), (3, 2), (4, 2), (5, 2), (6, 2), # VIERTEL + (7, 4), (8, 4), (9, 4)] # + VOR + elif m < 55: + return [(5, 1), (6, 1), (7, 1), (8, 1)] # ZEHN + VOR + else: + return [(0, 1), (1, 1), (2, 1), (3, 1), # FÜNF + VOR + (7, 4), (8, 4), (9, 4)] + + def hour_name(h, past_half): + """Gibt die Stunde zurück für die Wortuhr.""" + DE_HOURS = [ + "ZWÖLF", "EINS", "ZWEI", "DREI", "VIER", + "FÜNF", "SECHS", "SIEBEN", "ACHT", "NEUN", + "ZEHN", "ELF", "ZWÖLF" + ] + if past_half: + h = (h + 1) % 24 + if h == 0: + return "ZWÖLF" + return DE_HOURS[h % 12] + + past_half = m >= 20 + h_display = hour_name(h_, past_half) + lit_cells = lit_minute(m) + + # ---- Render ---- + # Hintergrund + draw.rectangle((x, y, x + w - 1, y + h - 1), fill=bg) + + # "ES IST" immer lit in Spalte 0 + _lit(0, 0); _lit(1, 0); _lit(3, 0); _lit(4, 0) + + # Minuten-Wörter + for (cx, cy) in lit_cells: + _lit(cx, cy) + + # "VOR" und "NACH" (Zeile 4) + if 5 <= m < 30: + _lit(0, 4); _lit(1, 4); _lit(2, 4); _lit(3, 4) # NACH + elif m >= 35 and m < 55: + _lit(7, 4); _lit(8, 4); _lit(9, 4) # VOR + + # Rest dim + for row in range(chars_y): + for col in range(chars_x): + if (col, row) not in lit_cells and not (row == 0 and col in (0, 1, 3, 4)): + _dim(col, row) + + # ---- Stunden-Balken unten ---- + # Zeile 6+7: Stunden-Name in großen Buchstaben unten zentriert + hour_str = h_display + hour_font_size = min(w // len(hour_str), h // 4) * 3 // 4 + hf = fonts.get(str(hour_font_size), fonts.get("60", fonts.get("default"))) + tw, th = measure(draw, hour_str, hf) + hour_y = y + h - th - pad + draw.text((x + (w - tw) // 2, hour_y), hour_str, font=hf, fill=fg) + + # ---- Datum + Wochentag ---- + if self.cfg("show_date", True): + date_str = now.strftime("%d. %b %Y") + df = fonts.get("16", fonts.get("default")) + dw, dh = measure(draw, date_str, df) + draw.text((x + (w - dw) // 2, y + pad), date_str, font=df, fill=accent) + + if self.cfg("show_weekday", True): + day_str = now.strftime("%A").upper() + df = fonts.get("14", fonts.get("default")) + dw, dh = measure(draw, day_str, df) + draw.text((x + (w - dw) // 2, y + pad + (18 if self.cfg("show_date", True) else 0)), + day_str, font=df, fill=accent) + + +# ---- Deutsches WordClock-Layout (11×8) ---- +# Jede Position ist ein Zeichen das gerendert wird. +# ' ' = Leerzeichen, rest = Buchstabe. +LAYOUT_DE = [ + ["E", "S", " ", "I", "S", "T", " ", " ", " ", " ", " "], + ["F", "Ü", "N", "F", " ", "Z", "E", "H", "N", " ", " "], + ["V", "I", "E", "R", "T", "E", "L", " ", "Z", "W", "A"], + ["N", "U", "L", "L", " ", "Z", "W", "A", "N", "Z", " "], + ["N", "A", "C", "H", " ", "V", "O", "R", " ", "H", "A"], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], + [" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "], +] diff --git a/templates/index.html b/templates/index.html index 9bf8c36..d9ab81e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -356,18 +356,26 @@ margin-bottom: 12px; min-height: 200px; } + /* ---- Grid Container ---- */ + .grid-preview { + display: grid; + grid-template-columns: repeat({{ grid.cols }}, 1fr); + grid-template-rows: repeat({{ grid.rows }}, 120px); + gap: 6px; + background: var(--border); + padding: 6px; + border-radius: 8px; + margin-bottom: 12px; + } + + /* ---- Empty Cell (drop target) ---- */ .grid-cell { background: var(--surface-2); border-radius: 4px; - transition: all 0.15s ease; + transition: background 0.12s ease, outline 0.12s ease; position: relative; - /* WICHTIG: in CSS Grid werden Items per Default auf ihre auto-size gestreckt; - wir wollen dass jede Cell die volle Row-Höhe einnimmt (drag-Targets). */ - width: 100%; - height: 100%; - min-height: 0; + overflow: visible; } - .grid-cell:empty { min-height: 100px; } .grid-cell.drop-target { background: var(--accent-glow); outline: 2px solid var(--accent); @@ -378,36 +386,48 @@ outline: 2px solid var(--alert); outline-offset: -2px; } + + /* ---- Occupied Cell: occupied items sit inside their origin cell ---- */ + .grid-cell.occupied { + background: transparent; + } + + /* ---- Grid Item: sits INSIDE its origin grid-cell ---- */ .grid-item { background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%); border: 1px solid var(--border-light); border-radius: 6px; - padding: 10px 12px; + padding: 8px 10px; display: flex; flex-direction: column; justify-content: space-between; overflow: visible; - position: relative; + position: absolute; + inset: 0; cursor: grab; user-select: none; - transition: box-shadow 0.15s ease, transform 0.05s ease; + transition: box-shadow 0.15s ease, border-color 0.15s ease; + box-sizing: border-box; } .grid-item:hover { border-color: var(--accent-bright); background: var(--surface-2); } .grid-item:active { cursor: grabbing; } .grid-item.dragging { - opacity: 0.35; + opacity: 0.25; cursor: grabbing; - transform: scale(0.97); } .grid-item.selected { border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent), 0 4px 16px var(--accent-glow); - z-index: 5; + box-shadow: 0 0 0 2px var(--accent), 0 4px 16px var(--accent-glow); + z-index: 10; } .grid-item.oob { border-color: var(--alert); background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%); } + + /* Item spans multiple cells (e.g. 2x2) — visually extend outside origin cell */ + .grid-item.span-2x2 { z-index: 5; } + .grid-item.span-4x1 { z-index: 5; } .grid-item-label { font-weight: 600; font-size: 0.85em; line-height: 1.2; pointer-events: none; @@ -1242,119 +1262,192 @@ saveTimer = setTimeout(saveLayout, 500); } + // ---- Which cells are occupied by which item index ---- + function occupiedCells() { + const map = {}; // cellIdx -> itemIdx + layoutItems.forEach((it, idx) => { + for (let dx = 0; dx < it.w; dx++) { + for (let dy = 0; dy < it.h; dy++) { + map[(it.x + dx) + ',' + (it.y + dy)] = idx; + } + } + }); + return map; + } + function canPlace(x, y, w, h, ignoreIdx) { if (x < 0 || y < 0 || x + w > gridCols || y + h > gridRows) return false; - const targetCells = new Set(); - for (let dx = 0; dx < w; dx++) for (let dy = 0; dy < h; dy++) targetCells.add((x+dx)+','+(y+dy)); - for (let i = 0; i < layoutItems.length; i++) { - if (i === ignoreIdx) continue; - const a = layoutItems[i]; - for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) { - if (targetCells.has((a.x+dx)+','+(a.y+dy))) return false; + const occ = occupiedCells(); + for (let dx = 0; dx < w; dx++) { + for (let dy = 0; dy < h; dy++) { + const cellIdx = (x + dx) + ',' + (y + dy); + const occupier = occ[cellIdx]; + if (occupier !== undefined && occupier !== ignoreIdx) return false; } } return true; } + // ---- Render: build cells + items once ---- function renderGrid() { const cont = document.getElementById('gridPreview'); + const cellGap = 6; // px, matches CSS gap + const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap; // account padding+gap + const cellH = 120; // matches CSS grid-template-rows + + // Build cell list (always recreate to get accurate measurements) cont.innerHTML = ''; + const cellEls = []; for (let i = 0; i < gridCols * gridRows; i++) { const cell = document.createElement('div'); cell.className = 'grid-cell'; cell.dataset.cell = i; cont.appendChild(cell); + cellEls.push(cell); } + + // Place items into their origin cell + const occ = occupiedCells(); layoutItems.forEach((it, idx) => { + const cellIdx = it.x + ',' + it.y; + const originCell = cont.querySelector(`[data-cell="${it.x + it.y * gridCols}"]`); + const div = document.createElement('div'); const m = widgetMeta(it.plugin); div.className = 'grid-item'; + if (it.w > 1 || it.h > 1) div.classList.add('span-2x2'); div.dataset.idx = idx; div.draggable = true; - div.style.gridColumn = `${it.x + 1} / span ${it.w}`; - div.style.gridRow = `${it.y + 1} / span ${it.h}`; - const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows; + + // OOB marker + const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0; if (hasOOB) div.classList.add('oob'); + div.innerHTML = `
${m.label}
${m.category}
-
${it.w}×${it.h} @ (${it.x},${it.y})
+
${it.w}×${it.h} · (${it.x},${it.y})
`; - cont.appendChild(div); + + if (originCell) { + originCell.classList.add('occupied'); + originCell.appendChild(div); + } else { + cont.appendChild(div); + } }); - attachDragHandlers(); + + // Mark occupied cells + Object.keys(occ).forEach(key => { + const [cx, cy] = key.split(',').map(Number); + const cell = cont.querySelector(`[data-cell="${cx + cy * gridCols}"]`); + if (cell) cell.classList.add('occupied'); + }); + + attachDragHandlers(cont, cellEls); updateLayoutStatus(); } - function attachDragHandlers() { - // Item drag (move) - document.querySelectorAll('.grid-item').forEach(el => { - el.addEventListener('click', (e) => { + // ---- Drag state ---- + let dragState = null; // { itemIdx, originItem, ghost } + + function attachDragHandlers(cont, cellEls) { + // ---- Item: drag start ---- + cont.querySelectorAll('.grid-item').forEach(el => { + el.addEventListener('click', e => { if (e.target.matches('.grid-item-delete, .grid-item-resize')) return; selectItem(parseInt(el.dataset.idx)); }); - el.addEventListener('dragstart', (e) => { - const idx = parseInt(el.dataset.idx); + + el.addEventListener('dragstart', e => { + const itemIdx = parseInt(el.dataset.idx); + const it = layoutItems[itemIdx]; e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(idx)); + e.dataTransfer.setData('text/plain', String(itemIdx)); el.classList.add('dragging'); + + // Transparent 1x1 pixel drag image so browser doesn't show a default ghost const empty = document.createElement('canvas'); empty.width = empty.height = 1; e.dataTransfer.setDragImage(empty, 0, 0); - }); - el.addEventListener('dragend', () => { - el.classList.remove('dragging'); - document.querySelectorAll('.grid-cell').forEach(c => c.classList.remove('drop-target', 'drop-invalid')); - renderGrid(); - debouncedSave(); + + // Snapshot for ghost + dragState = { itemIdx, originItem: { ...it }, ghost: null }; }); }); - // Cell drop targets - document.querySelectorAll('.grid-cell').forEach(cell => { - cell.addEventListener('dragover', (e) => { - const itemEl = document.querySelector('.grid-item.dragging'); - if (!itemEl) return; - // CRITICAL: preventDefault() muss IMMER aufgerufen werden, sonst lässt - // der Browser das Drop-Event gar nicht zu — egal ob fits oder nicht. + + // ---- Cell: dragover — show ghost preview ---- + cellEls.forEach(cell => { + cell.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; - const idx = parseInt(cell.dataset.cell); - const col = idx % gridCols; - const row = Math.floor(idx / gridCols); - const itemIdx = parseInt(itemEl.dataset.idx); - const it = layoutItems[itemIdx]; - const fits = canPlace(col, row, it.w, it.h, itemIdx); - cell.classList.toggle('drop-target', fits); - cell.classList.toggle('drop-invalid', !fits); - }); - cell.addEventListener('dragleave', () => { - cell.classList.remove('drop-target', 'drop-invalid'); - }); - cell.addEventListener('drop', (e) => { - e.preventDefault(); - const itemEl = document.querySelector('.grid-item.dragging'); - if (!itemEl) return; - const idx = parseInt(cell.dataset.cell); - const col = idx % gridCols; - const row = Math.floor(idx / gridCols); - const itemIdx = parseInt(itemEl.dataset.idx); + + if (!dragState) return; + const { itemIdx, originItem } = dragState; const it = layoutItems[itemIdx]; + const cellIdx = parseInt(cell.dataset.cell); + const col = cellIdx % gridCols; + const row = Math.floor(cellIdx / gridCols); const tx = Math.max(0, Math.min(gridCols - it.w, col)); const ty = Math.max(0, Math.min(gridRows - it.h, row)); - if (canPlace(tx, ty, it.w, it.h, itemIdx)) { - it.x = tx; it.y = ty; - renderGrid(); - debouncedSave(); + const fits = canPlace(tx, ty, it.w, it.h, itemIdx); + + cell.classList.toggle('drop-target', fits); + cell.classList.toggle('drop-invalid', !fits); + + // Ghost: show where item will land + updateGhost(cont, cellEls, tx, ty, it.w, it.h, itemIdx, fits); + }); + + cell.addEventListener('dragleave', e => { + // Only clear if leaving to outside the cell + if (!cell.contains(e.relatedTarget)) { + cell.classList.remove('drop-target', 'drop-invalid'); + removeGhost(cont); } }); + + cell.addEventListener('drop', e => { + e.preventDefault(); + if (!dragState) return; + const { itemIdx } = dragState; + const it = layoutItems[itemIdx]; + const cellIdx = parseInt(cell.dataset.cell); + const col = cellIdx % gridCols; + const row = Math.floor(cellIdx / gridCols); + const tx = Math.max(0, Math.min(gridCols - it.w, col)); + const ty = Math.max(0, Math.min(gridRows - it.h, row)); + + if (canPlace(tx, ty, it.w, it.h, itemIdx)) { + it.x = tx; it.y = ty; + renderGrid(); // full re-render after drop to reflect new layout + debouncedSave(); + } + removeGhost(cont); + cell.classList.remove('drop-target', 'drop-invalid'); + dragState = null; + }); }); - // Delete buttons - document.querySelectorAll('.grid-item-delete').forEach(btn => { - btn.addEventListener('click', async (e) => { + + // ---- Item dragend ---- + cont.querySelectorAll('.grid-item').forEach(el => { + el.addEventListener('dragend', () => { + el.classList.remove('dragging'); + cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid')); + removeGhost(cont); + dragState = null; + // Re-render to restore any mid-drag state changes (e.g. failed drop) + renderGrid(); + }); + }); + + // ---- Delete buttons ---- + cont.querySelectorAll('.grid-item-delete').forEach(btn => { + btn.addEventListener('click', async e => { e.stopPropagation(); const idx = parseInt(btn.dataset.idx); const item = layoutItems[idx]; @@ -1372,14 +1465,74 @@ toast(`"${m.label}" gelöscht`, 'success'); }); }); - // Resize handles - document.querySelectorAll('.grid-item-resize').forEach(h => { + + // ---- Resize handles ---- + cont.querySelectorAll('.grid-item-resize').forEach(h => { h.addEventListener('mousedown', startResize); }); - // Keyboard shortcuts: X to delete selected + + // ---- Keyboard: X to delete selected ---- document.addEventListener('keydown', onKey); } + // ---- Ghost preview during drag ---- + function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) { + removeGhost(cont); + const targetCell = cont.querySelector(`[data-cell="${x + y * gridCols}"]`); + if (!targetCell) return; + const ghost = document.createElement('div'); + ghost.id = 'drag-ghost'; + ghost.style.cssText = ` + position: absolute; + inset: 0; + border-radius: 6px; + pointer-events: none; + z-index: 20; + opacity: 0.55; + border: 2px dashed ${fits ? 'var(--accent)' : 'var(--alert)'}; + background: ${fits ? 'var(--accent-glow)' : 'rgba(248,113,113,0.15)'}; + display: flex; align-items: center; justify-content: center; + font-size: 0.8em; color: ${fits ? 'var(--accent)' : 'var(--alert)'}; + `; + ghost.textContent = fits ? `${w}×${h}` : 'Überlappung!'; + targetCell.appendChild(ghost); + } + + function removeGhost(cont) { + const g = cont.querySelector('#drag-ghost'); + if (g) g.remove(); + } + + // ---- Resize ---- + function startResize(e) { + e.preventDefault(); + e.stopPropagation(); + const idx = parseInt(e.currentTarget.dataset.resize); + const it = layoutItems[idx]; + const cellGap = 6; + const cont = document.getElementById('gridPreview'); + const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap; + const startX = e.clientX, startY = e.clientY; + const origW = it.w, origH = it.h; + + function onMove(ev) { + const dx = Math.round((ev.clientX - startX) / cellW); + const dy = Math.round((ev.clientY - startY) / 120); + const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx)); + const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy)); + if (newW === it.w && newH === it.h) return; + it.w = newW; it.h = newH; + renderGrid(); + } + function onUp() { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + debouncedSave(); + } + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + function onKey(e) { if (e.key === 'x' || e.key === 'X') { const sel = document.querySelector('.grid-item.selected'); @@ -1409,36 +1562,25 @@ if (el) el.classList.add('selected'); } - function startResize(e) { - e.preventDefault(); - e.stopPropagation(); - const idx = parseInt(e.currentTarget.dataset.resize); - const it = layoutItems[idx]; - const startCellW = document.querySelector('.grid-cell').offsetWidth + 6; - const startCellH = document.querySelector('.grid-cell').offsetHeight + 6; - const startX = e.clientX, startY = e.clientY; - const origW = it.w, origH = it.h; - - function onMove(ev) { - const dx = Math.round((ev.clientX - startX) / startCellW); - const dy = Math.round((ev.clientY - startY) / startCellH); - const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx)); - const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy)); - it.w = newW; it.h = newH; - renderGrid(); + function findOverlaps(items) { + const out = []; + for (let i = 0; i < items.length; i++) { + const a = items[i]; + const ac = new Set(); + for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy)); + for (let j = i+1; j < items.length; j++) { + const b = items[j]; + for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) { + if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; } + } + } } - function onUp() { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - debouncedSave(); - } - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); + return out; } function updateLayoutStatus() { const overlaps = findOverlaps(layoutItems); - const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows); + const oob = layoutItems.filter(it => it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0); const status = document.getElementById('layoutStatus'); if (status) { if (overlaps.length) { @@ -1455,29 +1597,12 @@ const used = new Set(layoutItems.map(it => it.plugin)); const pc = document.getElementById('pluginCount'); if (pc) pc.textContent = `${used.size} Plugins konfiguriert`; - // Sidebar-Badges const sl = document.getElementById('sbLayout'); if (sl) sl.textContent = layoutItems.length; const sp = document.getElementById('sbPlugins'); if (sp) sp.textContent = used.size; } - function findOverlaps(items) { - const out = []; - for (let i = 0; i < items.length; i++) { - const a = items[i]; - const ac = new Set(); - for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy)); - for (let j = i+1; j < items.length; j++) { - const b = items[j]; - for (let dx = 0; dx < b.w; dx++) for (let dy = 0; dy < b.h; dy++) { - if (ac.has((b.x+dx)+','+(b.y+dy))) { out.push([i, j]); break; } - } - } - } - return out; - } - async function addItem(ev) { ev.preventDefault(); const plugin = document.getElementById('newItemPlugin').value;