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:
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 = `
|
||||
<div>
|
||||
<div class="grid-item-label">${m.label}</div>
|
||||
<span class="grid-item-category">${m.category}</span>
|
||||
</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>
|
||||
<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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user