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)
This commit is contained in:
epaper-dashboard
2026-08-29 16:52:55 +04:00
parent 45d854e85e
commit 31d27ffa92
3 changed files with 469 additions and 119 deletions
+1
View File
@@ -145,6 +145,7 @@ def auto_size_for_plugin(plugin_name: str) -> tuple[int, int]:
"""Default size when user adds a new item.""" """Default size when user adds a new item."""
presets = { presets = {
"clock": (2, 2), "clock": (2, 2),
"clock_wordclock": (2, 2),
"weather": (2, 2), "weather": (2, 2),
"system": (2, 2), "system": (2, 2),
"minimax": (2, 2), "minimax": (2, 2),
+224
View File
@@ -0,0 +1,224 @@
"""WordClock-Plugin:Deutsche Wort-Uhr (QWATCHLayout).
Reines Wort-Uhr-Display im deutschen Stil:
ES IST <Fünf/Zehn/Viertel/Zwanzig> <Minuten> <nach/vor> <Stunde>
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"],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
[" ", " ", " ", " ", " ", " ", " ", " ", " ", " ", " "],
]
+244 -119
View File
@@ -356,18 +356,26 @@
margin-bottom: 12px; margin-bottom: 12px;
min-height: 200px; 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 { .grid-cell {
background: var(--surface-2); background: var(--surface-2);
border-radius: 4px; border-radius: 4px;
transition: all 0.15s ease; transition: background 0.12s ease, outline 0.12s ease;
position: relative; position: relative;
/* WICHTIG: in CSS Grid werden Items per Default auf ihre auto-size gestreckt; overflow: visible;
wir wollen dass jede Cell die volle Row-Höhe einnimmt (drag-Targets). */
width: 100%;
height: 100%;
min-height: 0;
} }
.grid-cell:empty { min-height: 100px; }
.grid-cell.drop-target { .grid-cell.drop-target {
background: var(--accent-glow); background: var(--accent-glow);
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
@@ -378,36 +386,48 @@
outline: 2px solid var(--alert); outline: 2px solid var(--alert);
outline-offset: -2px; 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 { .grid-item {
background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%); background: linear-gradient(135deg, var(--surface) 0%, var(--surface-2) 100%);
border: 1px solid var(--border-light); border: 1px solid var(--border-light);
border-radius: 6px; border-radius: 6px;
padding: 10px 12px; padding: 8px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
overflow: visible; overflow: visible;
position: relative; position: absolute;
inset: 0;
cursor: grab; cursor: grab;
user-select: none; 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:hover { border-color: var(--accent-bright); background: var(--surface-2); }
.grid-item:active { cursor: grabbing; } .grid-item:active { cursor: grabbing; }
.grid-item.dragging { .grid-item.dragging {
opacity: 0.35; opacity: 0.25;
cursor: grabbing; cursor: grabbing;
transform: scale(0.97);
} }
.grid-item.selected { .grid-item.selected {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent), 0 4px 16px var(--accent-glow); box-shadow: 0 0 0 2px var(--accent), 0 4px 16px var(--accent-glow);
z-index: 5; z-index: 10;
} }
.grid-item.oob { .grid-item.oob {
border-color: var(--alert); border-color: var(--alert);
background: linear-gradient(135deg, var(--surface) 0%, rgba(248, 113, 113, 0.1) 100%); 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 { .grid-item-label {
font-weight: 600; font-size: 0.85em; line-height: 1.2; font-weight: 600; font-size: 0.85em; line-height: 1.2;
pointer-events: none; pointer-events: none;
@@ -1242,119 +1262,192 @@
saveTimer = setTimeout(saveLayout, 500); 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) { function canPlace(x, y, w, h, ignoreIdx) {
if (x < 0 || y < 0 || x + w > gridCols || y + h > gridRows) return false; if (x < 0 || y < 0 || x + w > gridCols || y + h > gridRows) return false;
const targetCells = new Set(); const occ = occupiedCells();
for (let dx = 0; dx < w; dx++) for (let dy = 0; dy < h; dy++) targetCells.add((x+dx)+','+(y+dy)); for (let dx = 0; dx < w; dx++) {
for (let i = 0; i < layoutItems.length; i++) { for (let dy = 0; dy < h; dy++) {
if (i === ignoreIdx) continue; const cellIdx = (x + dx) + ',' + (y + dy);
const a = layoutItems[i]; const occupier = occ[cellIdx];
for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) { if (occupier !== undefined && occupier !== ignoreIdx) return false;
if (targetCells.has((a.x+dx)+','+(a.y+dy))) return false;
} }
} }
return true; return true;
} }
// ---- Render: build cells + items once ----
function renderGrid() { function renderGrid() {
const cont = document.getElementById('gridPreview'); 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 = ''; cont.innerHTML = '';
const cellEls = [];
for (let i = 0; i < gridCols * gridRows; i++) { for (let i = 0; i < gridCols * gridRows; i++) {
const cell = document.createElement('div'); const cell = document.createElement('div');
cell.className = 'grid-cell'; cell.className = 'grid-cell';
cell.dataset.cell = i; cell.dataset.cell = i;
cont.appendChild(cell); cont.appendChild(cell);
cellEls.push(cell);
} }
// Place items into their origin cell
const occ = occupiedCells();
layoutItems.forEach((it, idx) => { 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 div = document.createElement('div');
const m = widgetMeta(it.plugin); const m = widgetMeta(it.plugin);
div.className = 'grid-item'; div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx; div.dataset.idx = idx;
div.draggable = true; div.draggable = true;
div.style.gridColumn = `${it.x + 1} / span ${it.w}`;
div.style.gridRow = `${it.y + 1} / span ${it.h}`; // OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y >= gridRows; const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
if (hasOOB) div.classList.add('oob'); if (hasOOB) div.classList.add('oob');
div.innerHTML = ` div.innerHTML = `
<div> <div>
<div class="grid-item-label">${m.label}</div> <div class="grid-item-label">${m.label}</div>
<span class="grid-item-category">${m.category}</span> <span class="grid-item-category">${m.category}</span>
</div> </div>
<div class="grid-item-meta">${it.w}×${it.h} @ (${it.x},${it.y})</div> <div class="grid-item-meta">${it.w}×${it.h} · (${it.x},${it.y})</div>
<button class="grid-item-delete" data-idx="${idx}" title="löschen (X)">✕</button> <button class="grid-item-delete" data-idx="${idx}" title="löschen (X)">✕</button>
<div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div> <div class="grid-item-resize" data-resize="${idx}" title="Größe ändern (Ecke ziehen)"></div>
`; `;
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(); updateLayoutStatus();
} }
function attachDragHandlers() { // ---- Drag state ----
// Item drag (move) let dragState = null; // { itemIdx, originItem, ghost }
document.querySelectorAll('.grid-item').forEach(el => {
el.addEventListener('click', (e) => { 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; if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
selectItem(parseInt(el.dataset.idx)); 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.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', String(idx)); e.dataTransfer.setData('text/plain', String(itemIdx));
el.classList.add('dragging'); el.classList.add('dragging');
// Transparent 1x1 pixel drag image so browser doesn't show a default ghost
const empty = document.createElement('canvas'); const empty = document.createElement('canvas');
empty.width = empty.height = 1; empty.width = empty.height = 1;
e.dataTransfer.setDragImage(empty, 0, 0); e.dataTransfer.setDragImage(empty, 0, 0);
});
el.addEventListener('dragend', () => { // Snapshot for ghost
el.classList.remove('dragging'); dragState = { itemIdx, originItem: { ...it }, ghost: null };
document.querySelectorAll('.grid-cell').forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
renderGrid();
debouncedSave();
}); });
}); });
// Cell drop targets
document.querySelectorAll('.grid-cell').forEach(cell => { // ---- Cell: dragover — show ghost preview ----
cell.addEventListener('dragover', (e) => { cellEls.forEach(cell => {
const itemEl = document.querySelector('.grid-item.dragging'); cell.addEventListener('dragover', e => {
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.
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
const idx = parseInt(cell.dataset.cell);
const col = idx % gridCols; if (!dragState) return;
const row = Math.floor(idx / gridCols); const { itemIdx, originItem } = dragState;
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);
const it = layoutItems[itemIdx]; 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 tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row)); const ty = Math.max(0, Math.min(gridRows - it.h, row));
if (canPlace(tx, ty, it.w, it.h, itemIdx)) { const fits = canPlace(tx, ty, it.w, it.h, itemIdx);
it.x = tx; it.y = ty;
renderGrid(); cell.classList.toggle('drop-target', fits);
debouncedSave(); 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 => { // ---- Item dragend ----
btn.addEventListener('click', async (e) => { 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(); e.stopPropagation();
const idx = parseInt(btn.dataset.idx); const idx = parseInt(btn.dataset.idx);
const item = layoutItems[idx]; const item = layoutItems[idx];
@@ -1372,14 +1465,74 @@
toast(`"${m.label}" gelöscht`, 'success'); 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); h.addEventListener('mousedown', startResize);
}); });
// Keyboard shortcuts: X to delete selected
// ---- Keyboard: X to delete selected ----
document.addEventListener('keydown', onKey); 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) { function onKey(e) {
if (e.key === 'x' || e.key === 'X') { if (e.key === 'x' || e.key === 'X') {
const sel = document.querySelector('.grid-item.selected'); const sel = document.querySelector('.grid-item.selected');
@@ -1409,36 +1562,25 @@
if (el) el.classList.add('selected'); if (el) el.classList.add('selected');
} }
function startResize(e) { function findOverlaps(items) {
e.preventDefault(); const out = [];
e.stopPropagation(); for (let i = 0; i < items.length; i++) {
const idx = parseInt(e.currentTarget.dataset.resize); const a = items[i];
const it = layoutItems[idx]; const ac = new Set();
const startCellW = document.querySelector('.grid-cell').offsetWidth + 6; for (let dx = 0; dx < a.w; dx++) for (let dy = 0; dy < a.h; dy++) ac.add((a.x+dx)+','+(a.y+dy));
const startCellH = document.querySelector('.grid-cell').offsetHeight + 6; for (let j = i+1; j < items.length; j++) {
const startX = e.clientX, startY = e.clientY; const b = items[j];
const origW = it.w, origH = it.h; 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 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 onUp() { return out;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
debouncedSave();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
} }
function updateLayoutStatus() { function updateLayoutStatus() {
const overlaps = findOverlaps(layoutItems); 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'); const status = document.getElementById('layoutStatus');
if (status) { if (status) {
if (overlaps.length) { if (overlaps.length) {
@@ -1455,29 +1597,12 @@
const used = new Set(layoutItems.map(it => it.plugin)); const used = new Set(layoutItems.map(it => it.plugin));
const pc = document.getElementById('pluginCount'); const pc = document.getElementById('pluginCount');
if (pc) pc.textContent = `${used.size} Plugins konfiguriert`; if (pc) pc.textContent = `${used.size} Plugins konfiguriert`;
// Sidebar-Badges
const sl = document.getElementById('sbLayout'); const sl = document.getElementById('sbLayout');
if (sl) sl.textContent = layoutItems.length; if (sl) sl.textContent = layoutItems.length;
const sp = document.getElementById('sbPlugins'); const sp = document.getElementById('sbPlugins');
if (sp) sp.textContent = used.size; 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) { async function addItem(ev) {
ev.preventDefault(); ev.preventDefault();
const plugin = document.getElementById('newItemPlugin').value; const plugin = document.getElementById('newItemPlugin').value;