Merge fix/BUG-01-02-resize-pointer: Pointer-Events + rAF

BUG-01: Resize-Handle griff nicht — HTML5-draggable auf Items feuerte
dragstart bevor mousedown auf dem Handle ankam. Komplett raus, eigene
Pointer-Events für Move+Resize mit stopPropagation auf dem Resize-Handle.

BUG-02: Resize-Drag war zäh — renderGrid() pro mousemove. Jetzt rAF +
nur CSS-Geometrie-Update während Drag.

Tests: 7/7 grün.
This commit is contained in:
ki
2026-08-29 17:49:11 +04:00
2 changed files with 377 additions and 116 deletions
+180 -116
View File
@@ -452,6 +452,9 @@
width: 18px; height: 18px;
cursor: nwse-resize;
z-index: 2;
/* BUG-01: Hit-Fläche explizit, damit Pointer-Events sicher ankommen */
pointer-events: auto;
touch-action: none; /* BUG-01: verhindert Browser-Scroll auf Touch */
}
.grid-item-resize::before {
content: ''; position: absolute;
@@ -462,9 +465,14 @@
border-color: transparent transparent var(--fg-dim) transparent;
opacity: 0.5;
transition: opacity 0.15s;
pointer-events: none; /* BUG-01: ::before ist nur Deko */
}
.grid-item:hover .grid-item-resize::before { opacity: 1; }
.grid-item-resize:hover::before { border-bottom-color: var(--accent); }
/* BUG-01: gedrückte Resize-Handles deutlich machen */
.grid-item-resize:active::before { border-bottom-color: var(--accent-bright); opacity: 1; }
/* BUG-01: gedrückte Items nicht als "grabbing" zeigen — wir nutzen Pointer-Events */
.grid-item:active { cursor: grabbing; }
.layout-status {
font-size: 0.85em; color: var(--fg-muted);
@@ -1305,7 +1313,9 @@
div.className = 'grid-item';
if (it.w > 1 || it.h > 1) div.classList.add('span-2x2');
div.dataset.idx = idx;
div.draggable = true;
// BUG-01: kein HTML5-draggable mehr — eigene Pointer-Events übernehmen
// Move+Resize in startItemPointer / startResizePointer.
div.setAttribute('touch-action', 'none');
// OOB marker
const hasOOB = it.y + it.h > gridRows || it.x + it.w > gridCols || it.y < 0;
@@ -1341,99 +1351,44 @@
}
// ---- Drag state ----
let dragState = null; // { itemIdx, originItem, ghost }
let dragState = null; // { itemIdx, originItem, ghost } — nur für Move verwendet
// ---- BUG-01 + BUG-02: Pointer-Event-basiertes Move & Resize ----
//
// Problem vorher:
// - Items hatten `draggable=true` (HTML5-DnD). Resize-Handle war Kind des
// Items → Browser fired `dragstart` bevor mousedown greifen konnte →
// Resize ging gar nicht oder nur sporadisch.
// - Resize-Drag rief pro mousemove ein komplettes renderGrid() auf → Jank.
//
// Lösung:
// - HTML5-draggable komplett raus. Move + Resize über Pointer-Events.
// - Resize-Handle ist pointer-event-Ziel UND stoppt Propagation → kein
// versehentlicher Move-Start beim Ziehen am Handle.
// - Beide Aktionen nutzen requestAnimationFrame, und nur die Geometrie
// wird via CSS (grid-column/grid-row) aktualisiert — kein renderGrid()
// während Drag. Erst beim Drop / Resize-End kommt der volle re-render
// + save.
function attachDragHandlers(cont, cellEls) {
// ---- Item: drag start ----
// Click-to-select (Resize-Handle + Delete bleiben unberührt)
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 itemIdx = parseInt(el.dataset.idx);
const it = layoutItems[itemIdx];
e.dataTransfer.effectAllowed = 'move';
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);
// Snapshot for ghost
dragState = { itemIdx, originItem: { ...it }, ghost: null };
});
// BUG-01: Move-Drag startet auf pointerdown, AUSSER wenn das Target
// der Resize-Handle ist (der hat eigenen Handler und stoppt propagation).
el.addEventListener('pointerdown', startItemPointer);
});
// ---- Cell: dragover — show ghost preview ----
cellEls.forEach(cell => {
cell.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
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));
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;
});
// Resize-Handle: explizit eigene Pointer-Handler, stoppen sofort,
// damit der Item-Handler nicht mitfeuert.
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('pointerdown', startResizePointer);
});
// ---- 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 ----
// Delete buttons
cont.querySelectorAll('.grid-item-delete').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
@@ -1454,15 +1409,151 @@
});
});
// ---- Resize handles ----
cont.querySelectorAll('.grid-item-resize').forEach(h => {
h.addEventListener('mousedown', startResize);
});
// ---- Keyboard: X to delete selected ----
// Keyboard: X to delete selected
document.addEventListener('keydown', onKey);
}
// ---- Move (BUG-01: Pointer-Events, BUG-02: rAF + keine Render-per-Move) ----
function startItemPointer(e) {
// Resize-Handle hat eigenen Handler + stopPropagation — wir landen hier
// also nur, wenn User wirklich auf den Item-Body gedrückt hat.
if (e.target.matches('.grid-item-delete, .grid-item-resize')) return;
if (e.button !== undefined && e.button !== 0) return; // nur linke Maustaste
e.preventDefault();
const idx = parseInt(e.currentTarget.dataset.idx);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
// Zellgröße einmal messen (Grid ändert sich nicht während Drag)
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('dragging');
dragState = { itemIdx: idx, originItem: { ...it } };
let pendingCol = null, pendingRow = null, rafId = 0;
function pickCellFromEvent(ev) {
const rect = cont.getBoundingClientRect();
const lx = ev.clientX - rect.left - 6; // padding
const ly = ev.clientY - rect.top - 6;
const col = Math.max(0, Math.min(gridCols - 1, Math.floor(lx / (cellW + cellGap))));
const row = Math.max(0, Math.min(gridRows - 1, Math.floor(ly / (cellH + cellGap))));
// clamp so item stays in bounds
const tx = Math.max(0, Math.min(gridCols - it.w, col));
const ty = Math.max(0, Math.min(gridRows - it.h, row));
return [tx, ty];
}
function onMove(ev) {
const [tx, ty] = pickCellFromEvent(ev);
pendingCol = tx; pendingRow = ty;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (pendingCol === null) return;
const tx = pendingCol, ty = pendingRow;
const fits = canPlace(tx, ty, it.w, it.h, idx);
// BUG-02: nur Ghost + Highlight updaten, KEIN renderGrid
const cellIdx = tx + ty * gridCols;
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
const targetCell = cont.querySelector(`[data-cell="${cellIdx}"]`);
if (targetCell) targetCell.classList.toggle('drop-target', fits);
updateGhost(cont, cellEls, tx, ty, it.w, it.h, idx, fits);
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
e.currentTarget.classList.remove('dragging');
cellEls.forEach(c => c.classList.remove('drop-target', 'drop-invalid'));
removeGhost(cont);
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// Bei Drop: finale Position setzen + persistieren
if (pendingCol !== null) {
const tx = pendingCol, ty = pendingRow;
if (canPlace(tx, ty, it.w, it.h, idx)) {
it.x = tx; it.y = ty;
debouncedSave();
}
renderGrid();
}
dragState = null;
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- Resize (BUG-01: eigene Pointer-Events, BUG-02: rAF, kein Render-per-Move) ----
function startResizePointer(e) {
e.preventDefault();
e.stopPropagation(); // BUG-01: Item-Handler NICHT mitfeuern
if (e.button !== undefined && e.button !== 0) return;
const idx = parseInt(e.currentTarget.dataset.resize);
const it = layoutItems[idx];
const cont = document.getElementById('gridPreview');
const cellGap = 6;
const cellW = (cont.offsetWidth - 2 * 6) / gridCols - cellGap;
const cellH = 120;
const startX = e.clientX, startY = e.clientY;
const origW = it.w, origH = it.h;
e.currentTarget.setPointerCapture(e.pointerId);
let pendingW = origW, pendingH = origH, rafId = 0;
function applySize() {
// BUG-02: nur CSS-Geometrie des Items anfassen, kein renderGrid
const itemEl = cont.querySelector(`.grid-item[data-idx="${idx}"]`);
if (!itemEl) return;
itemEl.style.gridColumn = `${it.x + 1} / span ${it.w}`;
itemEl.style.gridRow = `${it.y + 1} / span ${it.h}`;
// Meta-Text aktualisieren
const meta = itemEl.querySelector('.grid-item-meta');
if (meta) meta.textContent = `${it.w}×${it.h} · (${it.x},${it.y})`;
}
function onMove(ev) {
const dx = Math.round((ev.clientX - startX) / cellW);
const dy = Math.round((ev.clientY - startY) / cellH);
const newW = Math.max(1, Math.min(gridCols - it.x, origW + dx));
const newH = Math.max(1, Math.min(gridRows - it.y, origH + dy));
pendingW = newW; pendingH = newH;
if (rafId) return;
rafId = requestAnimationFrame(() => {
rafId = 0;
if (it.w === pendingW && it.h === pendingH) return;
it.w = pendingW; it.h = pendingH;
applySize();
});
}
function onUp(ev) {
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
e.currentTarget.removeEventListener('pointercancel', onUp);
if (rafId) { cancelAnimationFrame(rafId); rafId = 0; }
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch (_) {}
// final snap auf letzte berechnete Größe
it.w = pendingW; it.h = pendingH;
debouncedSave();
// Re-render einmal, damit OOB-Markierung und Listener frisch sind
renderGrid();
}
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
e.currentTarget.addEventListener('pointercancel', onUp);
}
// ---- Ghost preview during drag ----
function updateGhost(cont, cellEls, x, y, w, h, excludeIdx, fits) {
removeGhost(cont);
@@ -1492,34 +1583,7 @@
}
// ---- 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);
}
// BUG-01/02: alte startResize()-Funktion entfernt — siehe startResizePointer().
function onKey(e) {
if (e.key === 'x' || e.key === 'X') {