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
+244 -119
View File
@@ -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;