diff --git a/css/main.css b/css/main.css index ccd4b99..1a3a3a7 100644 --- a/css/main.css +++ b/css/main.css @@ -9,6 +9,23 @@ width: fit-content; } +/* Feedback visual al reordenar (secciones y elementos de una sección de + "Patrón"), aplicado dinámicamente desde js/dnd.js. */ +.dragging { + opacity: 0.6; + box-shadow: 0 10px 20px rgb(0 0 0 / 0.3); + transform: scale(1.03) rotate(-1deg); + cursor: grabbing; +} + +/* Contenedor donde se está reordenando algo: lo marca como zona activa + mientras dura el arrastre. */ +.drop-target-active { + outline: 2px dashed #64908A; + outline-offset: 4px; + background-color: rgb(100 144 138 / 0.08); +} + /* En pantallas pequeñas, "Secciones" y "Vista previa" se comportan como pestañas: solo se muestra una a la vez (ver .tabs en index.html). A partir del breakpoint md ambos paneles se muestran siempre, lado a lado. diff --git a/js/dnd.js b/js/dnd.js index 3b6e551..fc5e2da 100644 --- a/js/dnd.js +++ b/js/dnd.js @@ -13,6 +13,11 @@ document.addEventListener('DOMContentLoaded', () => { // no interfiere con el drag & drop de los elementos arrastrables. let draggedSection = null; + // Igual que draggedSection, pero para reordenar un elemento ya soltado + // (un "grupo": cantidad + punto + botón eliminar) dentro de su zona de + // drop. Ver getItemAfterPoint() y setupDropZone() más abajo. + let draggedItem = null; + // La API nativa de Drag and Drop (HTML5) no está implementada en los // navegadores móviles: no se disparan dragstart/dragover/drop con el // dedo. Por eso todo el drag & drop de la página (reordenar secciones y @@ -56,6 +61,34 @@ document.addEventListener('DOMContentLoaded', () => { e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador. }); + // Igual que getSectionAfterY(), pero para una zona de drop con + // flex-wrap: los elementos pueden estar en distintas filas, así que no + // basta con comparar la coordenada Y. Se busca el elemento más cercano al + // punto (por distancia recta a su centro) y se decide si el punto cae + // antes o después según su posición horizontal respecto a ese centro. + function getItemAfterPoint(container, x, y) { + const items = Array.from(container.children).filter(child => !child.classList.contains('dragging')); + if (items.length === 0) return null; + + let nearestChild = null; + let nearestCenterX = 0; + let nearestDistance = Infinity; + + items.forEach(child => { + const box = child.getBoundingClientRect(); + const centerX = box.left + box.width / 2; + const centerY = box.top + box.height / 2; + const distance = Math.hypot(x - centerX, y - centerY); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestChild = child; + nearestCenterX = centerX; + } + }); + + return x < nearestCenterX ? nearestChild : nearestChild.nextElementSibling; + } + // --------------------------------------------------------------------- // Drag & drop de elementos arrastrables hacia una zona de destino // --------------------------------------------------------------------- @@ -125,9 +158,9 @@ document.addEventListener('DOMContentLoaded', () => { if (!sourceItem) return; const clone = sourceItem.cloneNode(true); - clone.removeAttribute('draggable'); clone.removeAttribute('id'); - clone.classList.add('inline-block'); + clone.classList.add('inline-block', 'cursor-move'); + clone.setAttribute('draggable', 'true'); // Arrastrable, pero para reordenar dentro del canvas (ver más abajo), no para volver a soltarlo como si fuera nuevo. const countInput = document.createElement('input'); countInput.type = 'number'; @@ -145,6 +178,54 @@ document.addEventListener('DOMContentLoaded', () => { removeItemBtn.textContent = '✕'; removeItemBtn.addEventListener('click', () => group.remove()); + // Reordenar el elemento dentro de su zona de drop: se distingue de + // "soltar un elemento nuevo" porque ese arrastre parte de la lista de + // "Elementos", no de un punto ya colocado en el canvas. + clone.addEventListener('dragstart', (e) => { + draggedItem = group; + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', 'item'); + group.classList.add('dragging'); + dropCanvas.classList.add('drop-target-active'); + }); + + clone.addEventListener('dragend', () => { + group.classList.remove('dragging'); + dropCanvas.classList.remove('drop-target-active'); + draggedItem = null; + }); + + // Equivalente táctil: mientras el dedo se mueve por encima del canvas, + // reutiliza getItemAfterPoint() para ir desplazando el grupo en vivo. + clone.addEventListener('touchstart', (e) => { + e.preventDefault(); + draggedItem = group; + group.classList.add('dragging'); + dropCanvas.classList.add('drop-target-active'); + + const onTouchMove = (moveEvent) => { + moveEvent.preventDefault(); + const point = getTouchPoint(moveEvent); + const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y); + if (afterElement == null) { + dropCanvas.appendChild(draggedItem); + } else if (afterElement !== draggedItem) { + dropCanvas.insertBefore(draggedItem, afterElement); + } + }; + + const onTouchEnd = () => { + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + group.classList.remove('dragging'); + dropCanvas.classList.remove('drop-target-active'); + draggedItem = null; + }; + + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + }, { passive: false }); + group.appendChild(countInput); group.appendChild(clone); group.appendChild(removeItemBtn); @@ -158,11 +239,27 @@ document.addEventListener('DOMContentLoaded', () => { function setupDropZone(dropCanvas, onChange) { dropCanvas.addEventListener('dragover', (e) => { e.preventDefault(); + + if (draggedItem) { + // No se está soltando un elemento nuevo, se está reordenando uno + // que ya estaba en este canvas. + e.dataTransfer.dropEffect = 'move'; + const afterElement = getItemAfterPoint(dropCanvas, e.clientX, e.clientY); + if (afterElement == null) { + dropCanvas.appendChild(draggedItem); + } else if (afterElement !== draggedItem) { + dropCanvas.insertBefore(draggedItem, afterElement); + } + return; + } + e.dataTransfer.dropEffect = 'copy'; }); dropCanvas.addEventListener('drop', (e) => { e.preventDefault(); + if (draggedItem) return; // El reordenado ya se hizo en el dragover. + const html = e.dataTransfer.getData('text/html'); if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada). @@ -290,11 +387,13 @@ document.addEventListener('DOMContentLoaded', () => { draggedSection = section; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores. - section.classList.add('dragging', 'opacity-50'); + section.classList.add('dragging'); + sectionsContainer.classList.add('drop-target-active'); }); dragHandle.addEventListener('dragend', () => { - section.classList.remove('dragging', 'opacity-50'); + section.classList.remove('dragging'); + sectionsContainer.classList.remove('drop-target-active'); draggedSection = null; }); @@ -304,7 +403,8 @@ document.addEventListener('DOMContentLoaded', () => { dragHandle.addEventListener('touchstart', (e) => { e.preventDefault(); draggedSection = section; - section.classList.add('dragging', 'opacity-50'); + section.classList.add('dragging'); + sectionsContainer.classList.add('drop-target-active'); const onTouchMove = (moveEvent) => { moveEvent.preventDefault(); @@ -320,7 +420,8 @@ document.addEventListener('DOMContentLoaded', () => { const onTouchEnd = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); - section.classList.remove('dragging', 'opacity-50'); + section.classList.remove('dragging'); + sectionsContainer.classList.remove('drop-target-active'); draggedSection = null; };