Files
crochet/js/dnd-items.js
T

205 lines
8.1 KiB
JavaScript

// Drag & drop de elementos arrastrables (los <li> de "Elementos") hacia una
// zona de destino (el "dnd-canvas" de una sección de "Patrón").
import { draggableItemsSource } from './dom-refs.js';
import { getTouchPoint, moveIfNeeded, getItemAfterPoint, itemDrag } from './drag-reorder.js';
// Hace arrastrable un <li> de la lista de "Elementos" y mete su HTML en el
// dataTransfer para poder clonarlo al soltarlo. addDndItem() se usa aquí
// dentro del listener aunque se declare más abajo: en JS los `function` se
// izan (hoisting), así que el orden dentro del módulo no importa.
export function makeDraggable(item) {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/html', item.outerHTML);
});
// Equivalente táctil: crea un clon flotante que sigue al dedo y, al
// soltar, mira qué elemento hay debajo del punto final para saber si
// cayó dentro de una zona de drop.
item.addEventListener('touchstart', (e) => {
e.preventDefault();
const start = getTouchPoint(e);
const ghost = item.cloneNode(true);
ghost.style.position = 'fixed';
ghost.style.left = `${start.x}px`;
ghost.style.top = `${start.y}px`;
ghost.style.pointerEvents = 'none';
ghost.style.opacity = '0.9';
ghost.style.zIndex = '9999';
// Se agranda y se desplaza por encima del punto de contacto: el dedo
// tapa el elemento original, así que a tamaño normal el usuario no
// vería qué está arrastrando.
ghost.style.transform = 'translate(-50%, -300%) scale(1.8)';
ghost.style.transformOrigin = 'center';
document.body.appendChild(ghost);
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const point = getTouchPoint(moveEvent);
ghost.style.left = `${point.x}px`;
ghost.style.top = `${point.y}px`;
};
const onTouchEnd = (endEvent) => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
ghost.remove();
const point = getTouchPoint(endEvent);
const target = document.elementFromPoint(point.x, point.y);
const dropCanvas = target && target.closest('.dnd-canvas');
if (dropCanvas) addDndItem(dropCanvas, item.textContent.trim(), 1);
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
}
// Añade a una zona de drop un grupo: cantidad (input numérico), el elemento
// (buscado en draggableItemsSource por su texto) y un botón para
// eliminarlo. Se usa tanto al soltar un elemento como al restaurar un
// patrón guardado previamente.
export function addDndItem(dropCanvas, label, count) {
const sourceItem = Array.from(draggableItemsSource.children)
.find(li => li.textContent.trim() === label);
if (!sourceItem) return;
const clone = sourceItem.cloneNode(true);
clone.removeAttribute('id');
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.
// Resalta la misma zona ampliada que usa setupDropZone() (la sección
// completa), no solo dropCanvas, para que el resaltado visual coincida
// con dónde realmente se puede soltar.
const highlightTarget = dropCanvas.closest('.top-section') || dropCanvas;
const countInput = document.createElement('input');
countInput.type = 'number';
countInput.min = '1';
countInput.value = count;
countInput.className = 'input input-bordered input-xs w-16';
const group = document.createElement('div');
group.className = 'inline-flex items-center gap-1';
// Botón para poder quitar el elemento si el usuario se equivoca.
const removeItemBtn = document.createElement('button');
removeItemBtn.type = 'button';
removeItemBtn.className = 'btn btn-xs btn-circle';
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) => {
itemDrag.item = group;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'item');
group.classList.add('dragging');
highlightTarget.classList.add('drop-target-active');
});
clone.addEventListener('dragend', () => {
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
itemDrag.item = 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();
itemDrag.item = group;
group.classList.add('dragging');
highlightTarget.classList.add('drop-target-active');
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const point = getTouchPoint(moveEvent);
const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y);
moveIfNeeded(dropCanvas, itemDrag.item, afterElement);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
itemDrag.item = null;
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
group.appendChild(countInput);
group.appendChild(clone);
group.appendChild(removeItemBtn);
dropCanvas.appendChild(group);
}
// Convierte cualquier contenedor en una zona donde soltar elementos
// arrastrables. `onChange`, si se pasa, se ejecuta cuando cambia el
// contenido del canvas (al soltar un elemento o cambiar la cantidad).
// `hitArea` (por defecto el propio dropCanvas) es donde se escuchan
// dragover/drop. dropCanvas encoge al tamaño exacto de sus elementos, así
// que soltar justo por encima del primero o por debajo del último se
// quedaba fuera de su zona; se pasa la sección completa (con su padding)
// como hitArea para tener el mismo margen que ya se amplió al reordenar
// secciones.
export function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
hitArea.addEventListener('dragover', (e) => {
e.preventDefault();
if (itemDrag.item) {
// 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);
moveIfNeeded(dropCanvas, itemDrag.item, afterElement);
return;
}
e.dataTransfer.dropEffect = 'copy';
});
hitArea.addEventListener('drop', (e) => {
e.preventDefault();
if (itemDrag.item) 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).
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const clone = wrapper.firstElementChild;
if (!clone) return;
addDndItem(dropCanvas, clone.textContent.trim(), 1);
});
if (onChange) {
dropCanvas.addEventListener('input', onChange); // Cambios en la cantidad.
new MutationObserver(onChange).observe(dropCanvas, { childList: true }); // Añadir/quitar elementos.
}
}
// Convierte el contenido de una zona de drop en texto tipo
// "3 Elemento A, 1 Elemento B".
export function getDndCanvasText(dndCanvas) {
const parts = Array.from(dndCanvas.children).map(group => {
const input = group.querySelector('input');
const label = group.querySelector('li');
if (!input || !label) return '';
return `${input.value} ${label.textContent}`;
});
return parts.filter(Boolean).join(', ');
}