feat: improved user feedback on dnd and reordering element list
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user