diff --git a/templates/index.html b/templates/index.html index 57325cd..c1796b5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -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') { diff --git a/tests/test_pointer_events.js b/tests/test_pointer_events.js new file mode 100644 index 0000000..7c1426f --- /dev/null +++ b/tests/test_pointer_events.js @@ -0,0 +1,197 @@ +// Behavior-Test für BUG-01: Pointer-Event-Trennung zwischen Item-Move und Resize-Handle. +// Wir laden das inline JS aus dem Template, mock-en ein minimales DOM, und prüfen: +// 1) Resize-Handle feuert startResizePointer (nicht startItemPointer). +// 2) Item-Body (außerhalb Handles) feuert startItemPointer. +// 3) Beim Resize-Start wird e.stopPropagation() aufgerufen → der Item-Handler sieht das Event NICHT. + +const fs = require('fs'); +const vm = require('vm'); + +// HTML laden, nur den