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:
+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