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