110 lines
5.1 KiB
JavaScript
110 lines
5.1 KiB
JavaScript
// Motor genérico de reordenado por arrastre, usado tanto para las secciones
|
|
// (arrastrando su cabecera) como para los elementos ya soltados dentro de
|
|
// una sección de "Patrón". 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, así que todo esto también se
|
|
// reimplementa con eventos touch usando getTouchPoint() como coordenadas
|
|
// comunes.
|
|
|
|
// Sección que se está arrastrando actualmente para reordenar, junto con el
|
|
// contenedor al que pertenece (el nivel superior, o el de un grupo) y la
|
|
// zona ampliada que se resalta mientras dura el arrastre. Se exporta como
|
|
// un objeto mutable (en vez de variables sueltas con getters/setters)
|
|
// porque quien arrastra (sections.js) y quien reordena
|
|
// (setupSectionsReorder, aquí mismo) necesitan compartir el mismo estado
|
|
// en todo momento.
|
|
export const sectionDrag = { section: null, container: null, hitArea: null };
|
|
|
|
// Igual que sectionDrag, pero para reordenar un elemento ya soltado (un
|
|
// "grupo": cantidad + punto + botón eliminar) dentro de su zona de drop.
|
|
export const itemDrag = { item: null };
|
|
|
|
export function getTouchPoint(e) {
|
|
const touch = e.touches[0] || e.changedTouches[0];
|
|
return { x: touch.clientX, y: touch.clientY };
|
|
}
|
|
|
|
// Mueve `node` a la posición indicada por `afterElement` (null = al final)
|
|
// solo si no está ya ahí. dragover/touchmove se disparan decenas de veces
|
|
// por segundo mientras se arrastra, y sin esta comprobación se repetía un
|
|
// insertBefore/appendChild en cada uno aunque la posición no cambiase: eso
|
|
// fuerza un reflow y una mutación del DOM de más en cada evento, lo que
|
|
// además retrasa el propio cálculo de posiciones del siguiente evento (los
|
|
// rects de los elementos vecinos se recalculan sin necesidad) y podía hacer
|
|
// que el reordenado se notase bloqueado o dando tirones cerca del límite
|
|
// entre dos elementos.
|
|
export function moveIfNeeded(container, node, afterElement) {
|
|
if (afterElement == null) {
|
|
if (container.lastElementChild !== node) container.appendChild(node);
|
|
} else if (afterElement !== node && node.nextElementSibling !== afterElement) {
|
|
container.insertBefore(node, afterElement);
|
|
}
|
|
}
|
|
|
|
// Dado un contenedor de secciones y un punto Y del cursor, devuelve la
|
|
// sección justo debajo de la que hay que insertar la sección arrastrada (o
|
|
// null si va al final). Sirve tanto para #panel-top-sections como para el
|
|
// contenedor interno de cualquier sección de tipo "Grupo".
|
|
export function getSectionAfterY(container, y) {
|
|
const sections = Array.from(container.querySelectorAll(':scope > .top-section:not(.dragging)'));
|
|
return sections.reduce((closest, child) => {
|
|
const box = child.getBoundingClientRect();
|
|
const offset = y - box.top - box.height / 2;
|
|
if (offset < 0 && offset > closest.offset) {
|
|
return { offset, element: child };
|
|
}
|
|
return closest;
|
|
}, { offset: -Infinity, element: null }).element;
|
|
}
|
|
|
|
// Da de alta el reordenado por arrastre para un contenedor de secciones (el
|
|
// de nivel superior, o el de un grupo). `hitArea` es donde se escuchan
|
|
// dragover/drop: el contenedor encoge al tamaño exacto de sus secciones,
|
|
// así que soltar justo por encima de la primera o por debajo de la última
|
|
// quedaría fuera de su zona; por eso se usa como hitArea su envoltorio con
|
|
// padding (#panel-top, o la sección "Grupo" completa).
|
|
export function setupSectionsReorder(container, hitArea) {
|
|
hitArea.addEventListener('dragover', (e) => {
|
|
if (sectionDrag.container !== container) return; // El arrastre no pertenece a este contenedor (o no hay ninguno).
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
|
|
// Mueve en vivo la sección arrastrada a su nueva posición según el cursor.
|
|
const afterElement = getSectionAfterY(container, e.clientY);
|
|
moveIfNeeded(container, sectionDrag.section, afterElement);
|
|
});
|
|
|
|
hitArea.addEventListener('drop', (e) => {
|
|
if (sectionDrag.container !== container) return;
|
|
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.
|
|
export 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;
|
|
}
|