feat: improved user feedback on dnd and reordering element list

This commit is contained in:
2026-07-09 08:30:18 +02:00
parent ebb1fc2906
commit a923559d1a
2 changed files with 124 additions and 6 deletions
+17
View File
@@ -9,6 +9,23 @@
width: fit-content; 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 /* 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 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. partir del breakpoint md ambos paneles se muestran siempre, lado a lado.
+107 -6
View File
@@ -13,6 +13,11 @@ document.addEventListener('DOMContentLoaded', () => {
// no interfiere con el drag & drop de los elementos arrastrables. // no interfiere con el drag & drop de los elementos arrastrables.
let draggedSection = null; 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 // La API nativa de Drag and Drop (HTML5) no está implementada en los
// navegadores móviles: no se disparan dragstart/dragover/drop con el // 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 // 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. 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 // Drag & drop de elementos arrastrables hacia una zona de destino
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -125,9 +158,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (!sourceItem) return; if (!sourceItem) return;
const clone = sourceItem.cloneNode(true); const clone = sourceItem.cloneNode(true);
clone.removeAttribute('draggable');
clone.removeAttribute('id'); 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'); const countInput = document.createElement('input');
countInput.type = 'number'; countInput.type = 'number';
@@ -145,6 +178,54 @@ document.addEventListener('DOMContentLoaded', () => {
removeItemBtn.textContent = '✕'; removeItemBtn.textContent = '✕';
removeItemBtn.addEventListener('click', () => group.remove()); 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(countInput);
group.appendChild(clone); group.appendChild(clone);
group.appendChild(removeItemBtn); group.appendChild(removeItemBtn);
@@ -158,11 +239,27 @@ document.addEventListener('DOMContentLoaded', () => {
function setupDropZone(dropCanvas, onChange) { function setupDropZone(dropCanvas, onChange) {
dropCanvas.addEventListener('dragover', (e) => { dropCanvas.addEventListener('dragover', (e) => {
e.preventDefault(); 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'; e.dataTransfer.dropEffect = 'copy';
}); });
dropCanvas.addEventListener('drop', (e) => { dropCanvas.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
if (draggedItem) return; // El reordenado ya se hizo en el dragover.
const html = e.dataTransfer.getData('text/html'); const html = e.dataTransfer.getData('text/html');
if (!html) return; // No es un elemento arrastrable (p.ej. es una sección siendo reordenada). 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; draggedSection = section;
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores. 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', () => { dragHandle.addEventListener('dragend', () => {
section.classList.remove('dragging', 'opacity-50'); section.classList.remove('dragging');
sectionsContainer.classList.remove('drop-target-active');
draggedSection = null; draggedSection = null;
}); });
@@ -304,7 +403,8 @@ document.addEventListener('DOMContentLoaded', () => {
dragHandle.addEventListener('touchstart', (e) => { dragHandle.addEventListener('touchstart', (e) => {
e.preventDefault(); e.preventDefault();
draggedSection = section; draggedSection = section;
section.classList.add('dragging', 'opacity-50'); section.classList.add('dragging');
sectionsContainer.classList.add('drop-target-active');
const onTouchMove = (moveEvent) => { const onTouchMove = (moveEvent) => {
moveEvent.preventDefault(); moveEvent.preventDefault();
@@ -320,7 +420,8 @@ document.addEventListener('DOMContentLoaded', () => {
const onTouchEnd = () => { const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd); document.removeEventListener('touchend', onTouchEnd);
section.classList.remove('dragging', 'opacity-50'); section.classList.remove('dragging');
sectionsContainer.classList.remove('drop-target-active');
draggedSection = null; draggedSection = null;
}; };