Files
crochet/js/drag-reorder.js
T

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;
}