document.addEventListener('DOMContentLoaded', () => {
// Referencias a los elementos fijos del documento.
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
const panelTop = document.getElementById('panel-top'); // Todo el panel "Secciones" (incluye los botones "+ tipo"), usado como zona de detección al reordenar.
const sectionsContainer = document.getElementById('panel-top-sections'); // Contiene las secciones que el usuario va añadiendo.
const draggableItemsSource = document.getElementById('draggable-items-source'); // Lista oculta con los elementos disponibles para arrastrar: fuente de datos única, se clona (visible) en cada sección de tipo "arrastrar y soltar".
// ---------------------------------------------------------------------
// Reordenar secciones (arrastrando toda la cabecera de cada una)
// ---------------------------------------------------------------------
// 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 usan como
// "memoria" compartida entre los listeners en lugar de dataTransfer, así
// no interfieren con el drag & drop de los elementos arrastrables.
let draggedSection = null;
let draggedSectionContainer = null;
let draggedSectionHitArea = 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
// 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
// soltar elementos) también se reimplementa con eventos touch, usando
// este punto como coordenadas comunes.
function getTouchPoint(e) {
const touch = e.touches[0] || e.changedTouches[0];
return { x: touch.clientX, y: touch.clientY };
}
// 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".
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).
function setupSectionsReorder(container, hitArea) {
hitArea.addEventListener('dragover', (e) => {
if (draggedSectionContainer !== 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, draggedSection, afterElement);
});
hitArea.addEventListener('drop', (e) => {
if (draggedSectionContainer !== 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.
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;
}
// 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.
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);
}
}
// ---------------------------------------------------------------------
// Drag & drop de elementos arrastrables hacia una zona de destino
// ---------------------------------------------------------------------
// Hace arrastrable un
de la lista de "Elementos" y mete su HTML en
// el dataTransfer para poder clonarlo al soltarlo. addDndItem() se define
// más abajo; se referencia aquí dentro del listener, no al declarar la
// función, así que el orden no importa.
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 (ver buildSectionsFrom más abajo).
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) => {
draggedItem = 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');
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');
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, draggedItem, afterElement);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
group.classList.remove('dragging');
highlightTarget.classList.remove('drop-target-active');
draggedItem = 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.
function setupDropZone(dropCanvas, onChange, hitArea = dropCanvas) {
hitArea.addEventListener('dragover', (e) => {
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);
moveIfNeeded(dropCanvas, draggedItem, afterElement);
return;
}
e.dataTransfer.dropEffect = 'copy';
});
hitArea.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedItem) 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.
}
}
// ---------------------------------------------------------------------
// Generación del resultado (#panel-output)
// ---------------------------------------------------------------------
// Convierte el contenido de una zona de drop en texto tipo
// "3 Elemento A, 1 Elemento B".
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(', ');
}
// Añade una línea de texto a `target` (si no está vacía).
function appendOutputLine(target, tag, text, className) {
if (!text) return;
const el = document.createElement(tag);
el.className = className;
el.textContent = text;
target.appendChild(el);
}
// Aporta a `target` lo que corresponda según el tipo de `section`: título,
// subtítulo, texto, drag&drop o imagen. Si `section` es un "Grupo", se
// recorren sus propias secciones (recursivamente) dentro de un bloque
// aparte, con una guía visual a la izquierda. El chequeo de grupo va
// primero y siempre `return`: si no, un grupo que contenga p.ej. una
// sección de texto haría que el `querySelector('textarea')` de más abajo
// encontrase esa textarea anidada y tratase el grupo entero como texto.
function renderOutputSection(section, target) {
const groupContainer = section.querySelector('.group-container');
if (groupContainer) {
const wrapper = document.createElement('div');
wrapper.className = 'pl-4 border-l-2 border-base-300 my-2 flex flex-col gap-1';
Array.from(groupContainer.children).forEach(child => renderOutputSection(child, wrapper));
if (wrapper.children.length) target.appendChild(wrapper);
return;
}
const titleInput = section.querySelector('.title-input');
if (titleInput) {
appendOutputLine(target, 'h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1');
return;
}
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) {
appendOutputLine(target, 'h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1');
return;
}
const textarea = section.querySelector('textarea');
if (textarea) {
appendOutputLine(target, 'p', textarea.value.trim(), '');
return;
}
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {
appendOutputLine(target, 'p', getDndCanvasText(dndCanvas), '');
return;
}
// Sección de imagen: si ya hay una imagen cargada, se copia (con el
// tamaño limitado) al resultado.
const previewImg = section.querySelector('img');
if (previewImg) {
if (previewImg.src && !previewImg.classList.contains('hidden')) {
const img = document.createElement('img');
img.src = previewImg.src;
img.className = 'max-w-full max-h-48 object-contain rounded-box my-2';
target.appendChild(img);
}
}
}
// Recorre todas las secciones de nivel superior (en su orden actual) y
// reconstruye #panel-output desde cero.
function renderOutput() {
outputText.innerHTML = '';
// Si aún no se ha añadido ninguna sección, avisar en vez de dejar el
// resultado vacío.
if (sectionsContainer.children.length === 0) {
appendOutputLine(outputText, 'p', outputText.dataset.emptyMessage, 'text-base-content/50 italic');
return;
}
Array.from(sectionsContainer.children).forEach(section => renderOutputSection(section, outputText));
}
// El resultado se regenera con cualquier cambio dentro de las secciones:
// escribir texto/título ('input'), elegir una imagen ('change'), o
// añadir/quitar/reordenar secciones o elementos soltados (MutationObserver).
// Al estar en #panel-top-sections y usar subtree:true, esto ya cubre
// también los cambios dentro de las secciones anidadas en un "Grupo".
sectionsContainer.addEventListener('input', renderOutput);
sectionsContainer.addEventListener('change', renderOutput);
new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true });
// ---------------------------------------------------------------------
// Creación de secciones (título, texto, imagen, drag & drop, grupo)
// ---------------------------------------------------------------------
// Crea el "armazón" común a toda sección: el recuadro, la cabecera (asa
// para reordenar, tipo de sección, botón de colapsar y de eliminar) y el
// cuerpo donde cada función addXxxSection() mete su contenido específico.
// Devuelve { section, body }: quien llama decide en qué contenedor meter
// `section` (el nivel superior, o el de un grupo), y `body` es donde hay
// que appendear el contenido propio de cada tipo.
function createSectionShell(typeLabel) {
const section = document.createElement('div');
section.className = 'top-section border border-base-300 rounded-box p-4 flex flex-col gap-2';
// Toda la cabecera es la zona de arrastre para reordenar (no solo el
// icono "⠿"), así que draggable/dragstart/etc. van en `header`.
const header = document.createElement('div');
header.className = 'flex items-center gap-2 cursor-move';
header.title = sectionsContainer.dataset.dragTitle;
header.setAttribute('draggable', 'true');
const dragHandle = document.createElement('span');
dragHandle.className = 'select-none text-base-content/50 px-1';
dragHandle.textContent = '⠿';
header.addEventListener('dragstart', (e) => {
draggedSection = section;
draggedSectionContainer = section.parentElement;
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', 'section'); // Necesario para que el arrastre se inicie en todos los navegadores.
section.classList.add('dragging');
draggedSectionHitArea.classList.add('drop-target-active');
});
header.addEventListener('dragend', () => {
section.classList.remove('dragging');
draggedSectionHitArea.classList.remove('drop-target-active');
draggedSection = null;
draggedSectionContainer = null;
draggedSectionHitArea = null;
});
// Equivalente táctil del reordenado por dragover/drop de setupSectionsReorder():
// mientras el dedo se mueve, se reutiliza getSectionAfterY() para ir
// desplazando la sección en vivo dentro de su contenedor actual.
header.addEventListener('touchstart', (e) => {
if (e.target.closest('button')) return; // No interferir con "colapsar" ni "eliminar".
e.preventDefault();
draggedSection = section;
draggedSectionContainer = section.parentElement;
draggedSectionHitArea = draggedSectionContainer.closest('.top-section') || panelTop;
section.classList.add('dragging');
draggedSectionHitArea.classList.add('drop-target-active');
const onTouchMove = (moveEvent) => {
moveEvent.preventDefault();
const { y } = getTouchPoint(moveEvent);
const afterElement = getSectionAfterY(draggedSectionContainer, y);
moveIfNeeded(draggedSectionContainer, draggedSection, afterElement);
};
const onTouchEnd = () => {
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
section.classList.remove('dragging');
draggedSectionHitArea.classList.remove('drop-target-active');
draggedSection = null;
draggedSectionContainer = null;
draggedSectionHitArea = null;
};
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}, { passive: false });
// Botón para colapsar/expandir el cuerpo de la sección, útil cuando hay
// muchas secciones o su contenido (p.ej. una imagen) ocupa mucho espacio.
const collapseBtn = document.createElement('button');
collapseBtn.type = 'button';
collapseBtn.className = 'collapse-btn btn btn-xs btn-ghost';
collapseBtn.textContent = '▾';
collapseBtn.title = sectionsContainer.dataset.collapseTitle;
// Etiqueta que indica de qué tipo es la sección (Título, Texto, Imagen...).
const badge = document.createElement('span');
badge.className = 'badge badge-sm badge-outline select-none';
badge.textContent = typeLabel;
const spacer = document.createElement('div');
spacer.className = 'flex-1';
// Botón para duplicar la sección: se apoya en serializeSection() /
// addSectionFromData() (definidas más abajo, pero disponibles aquí por
// el hoisting de las funciones declaradas con "function") para clonar su
// contenido, sea del tipo que sea (incluido un "Grupo" con todo lo que
// contenga dentro).
const duplicateBtn = document.createElement('button');
duplicateBtn.type = 'button';
duplicateBtn.className = 'btn btn-xs btn-circle';
duplicateBtn.textContent = '⧉';
duplicateBtn.title = sectionsContainer.dataset.duplicateTitle;
duplicateBtn.addEventListener('click', () => duplicateSection(section));
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-xs btn-circle';
removeBtn.textContent = '✕';
removeBtn.title = sectionsContainer.dataset.removeTitle;
removeBtn.addEventListener('click', () => section.remove());
header.appendChild(dragHandle);
header.appendChild(collapseBtn);
header.appendChild(badge);
header.appendChild(spacer);
header.appendChild(duplicateBtn);
header.appendChild(removeBtn);
const body = document.createElement('div');
body.className = 'section-body flex flex-col gap-2';
collapseBtn.addEventListener('click', () => {
body.classList.toggle('hidden');
collapseBtn.textContent = body.classList.contains('hidden') ? '▸' : '▾';
});
section.appendChild(header);
section.appendChild(body);
return { section, body };
}
// Sección de título: un input de una línea que se muestra como en el
// resultado. `data` es el dataset del botón "+ Título" (data-type-label,
// data-placeholder). `text`, si se pasa, precarga el valor (se usa al
// restaurar un patrón guardado). `container` es dónde se añade la
// sección: #panel-top-sections por defecto, o el de un grupo.
function addTitleSection(data, text = '', container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'title-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.value = text;
body.appendChild(input);
container.appendChild(section);
}
// Sección de subtítulo: igual que la de título pero se muestra como
// (más pequeño) en el resultado, para marcar un encabezado secundario.
function addSubtitleSection(data, text = '', container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const input = document.createElement('input');
input.type = 'text';
input.className = 'subtitle-input input input-bordered w-full';
input.placeholder = data.placeholder;
input.value = text;
body.appendChild(input);
container.appendChild(section);
}
// Sección de texto libre.
function addTextSection(data, text = '', container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const textarea = document.createElement('textarea');
textarea.className = 'textarea textarea-bordered w-full';
textarea.placeholder = data.placeholder;
textarea.value = text;
body.appendChild(textarea);
container.appendChild(section);
}
// Sección de imagen: selector de archivo + vista previa (tamaño limitado
// para que una foto de alta resolución no ocupe toda la pantalla).
// `dataUrl`, si se pasa, precarga la imagen (al restaurar un patrón guardado).
// Se guarda como data URL en base64 (no con URL.createObjectURL) porque un
// blob: solo es válido durante la sesión actual y no se puede persistir.
function addImageSection(data, dataUrl = '', container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.className = 'file-input file-input-bordered w-full mb-2';
const img = document.createElement('img');
img.className = 'max-w-full max-h-48 object-contain rounded-box hidden';
if (dataUrl) {
img.src = dataUrl;
img.classList.remove('hidden');
}
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.addEventListener('load', () => {
img.src = reader.result;
img.classList.remove('hidden');
renderOutput(); // La lectura es asíncrona: el 'change' ya burbujeó antes de que la imagen estuviera lista.
});
reader.readAsDataURL(file);
});
body.appendChild(fileInput);
body.appendChild(img);
container.appendChild(section);
}
// Sección de "arrastrar y soltar": una zona de drop propia junto a su
// propia lista de elementos arrastrables (clonada de la fuente), para
// que cada sección de este tipo sea independiente de las demás.
// `items`, si se pasa, precarga los elementos ya soltados: [{ label, count }].
function addDndSection(data, items = [], container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
const row = document.createElement('div');
row.className = 'flex flex-col sm:flex-row gap-4';
const dndCanvas = document.createElement('div');
dndCanvas.className = 'dnd-canvas flex-1 flex flex-wrap items-center gap-2 min-h-24';
const elementsPanel = document.createElement('aside');
elementsPanel.className = 'w-full sm:w-48 sm:shrink-0 border border-base-300 rounded-box p-4';
const elementsTitle = document.createElement('h3');
elementsTitle.className = 'font-semibold mb-2';
elementsTitle.textContent = data.elementsLabel;
const list = draggableItemsSource.cloneNode(true);
list.removeAttribute('id'); // Evitar ids duplicados: puede haber varias secciones de este tipo.
list.classList.remove('hidden'); // La fuente está oculta; el clon sí debe mostrarse.
list.querySelectorAll('li').forEach(makeDraggable);
elementsPanel.appendChild(elementsTitle);
elementsPanel.appendChild(list);
row.appendChild(dndCanvas);
row.appendChild(elementsPanel);
body.appendChild(row);
container.appendChild(section);
setupDropZone(dndCanvas, undefined, section);
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
}
// Sección de "grupo": no tiene contenido propio, solo agrupa varias
// secciones dentro de sí misma (con su propia barra de "+ tipo" y su
// propio orden independiente), para organizar visualmente un conjunto de
// secciones relacionadas. Se apoya en las mismas funciones addXxxSection
// y en setupSectionsReorder(), pasándoles su contenedor interno en vez
// del de nivel superior. `children`, si se pasa, precarga su contenido
// (al restaurar un patrón guardado); permite grupos anidados sin más.
function addGroupSection(data, children = [], container = sectionsContainer) {
const { section, body } = createSectionShell(data.typeLabel);
section.classList.add('bg-base-200/30');
// Marcado con la clase 'group-container' para que renderOutputSection()
// y serializeSection() puedan reconocer que esta sección es un grupo.
const groupContainer = document.createElement('div');
groupContainer.className = 'group-container flex flex-col gap-4 pl-4 border-l-2 border-base-300';
const toolbar = document.createElement('div');
toolbar.className = 'flex flex-wrap gap-2 mb-4';
[
[addTitleBtn, addTitleSection],
[addSubtitleBtn, addSubtitleSection],
[addTextBtn, addTextSection],
[addImageBtn, addImageSection],
[addDndBtn, addDndSection],
[addGroupBtn, addGroupSection],
].forEach(([sourceBtn, addFn]) => {
const btn = sourceBtn.cloneNode(true);
btn.removeAttribute('id'); // Puede haber varios grupos, cada uno con su propia copia de estos botones.
btn.addEventListener('click', () => addFn(sourceBtn.dataset, undefined, groupContainer));
toolbar.appendChild(btn);
});
body.appendChild(toolbar);
body.appendChild(groupContainer);
container.appendChild(section);
setupSectionsReorder(groupContainer, section);
buildSectionsFrom(children, groupContainer);
}
// Botones de la barra superior para añadir cada tipo de sección. Los textos
// (etiqueta de tipo, placeholders...) se leen de sus data-* en el HTML, no
// están escritos aquí, para que la plantilla Django pueda traducirlos.
const addTitleBtn = document.getElementById('add-title-section');
const addSubtitleBtn = document.getElementById('add-subtitle-section');
const addTextBtn = document.getElementById('add-text-section');
const addImageBtn = document.getElementById('add-image-section');
const addDndBtn = document.getElementById('add-dnd-section');
const addGroupBtn = document.getElementById('add-group-section');
addTitleBtn.addEventListener('click', () => addTitleSection(addTitleBtn.dataset));
addSubtitleBtn.addEventListener('click', () => addSubtitleSection(addSubtitleBtn.dataset));
addTextBtn.addEventListener('click', () => addTextSection(addTextBtn.dataset));
addImageBtn.addEventListener('click', () => addImageSection(addImageBtn.dataset));
addDndBtn.addEventListener('click', () => addDndSection(addDndBtn.dataset));
addGroupBtn.addEventListener('click', () => addGroupSection(addGroupBtn.dataset));
// Reordenado por arrastre de las secciones de nivel superior (ver
// addGroupSection() para el de dentro de un grupo).
setupSectionsReorder(sectionsContainer, panelTop);
// ---------------------------------------------------------------------
// Colapsar / expandir todas las secciones de una vez
// ---------------------------------------------------------------------
// Colapsa o expande a la vez todas las secciones que haya, sin importar
// su nivel de anidamiento (querySelectorAll recorre también las que
// están dentro de un "Grupo"), sincronizando tanto su cuerpo como el
// icono de su propio botón de colapsar individual.
function setAllSectionsCollapsed(collapsed) {
sectionsContainer.querySelectorAll('.section-body').forEach(body => {
body.classList.toggle('hidden', collapsed);
});
sectionsContainer.querySelectorAll('.collapse-btn').forEach(btn => {
btn.textContent = collapsed ? '▸' : '▾';
});
}
const toggleCollapseAllBtn = document.getElementById('toggle-collapse-all');
toggleCollapseAllBtn.addEventListener('click', () => {
// Se decide según el estado actual (en vez de recordar uno propio) para
// que el botón siga teniendo sentido aunque el usuario haya colapsado o
// expandido secciones sueltas a mano: si queda alguna expandida, el
// siguiente clic las colapsa todas; si no queda ninguna, las expande.
const anyExpanded = Array.from(sectionsContainer.querySelectorAll('.section-body'))
.some(body => !body.classList.contains('hidden'));
setAllSectionsCollapsed(anyExpanded);
toggleCollapseAllBtn.textContent = anyExpanded
? toggleCollapseAllBtn.dataset.expandAllLabel
: toggleCollapseAllBtn.dataset.collapseAllLabel;
});
// ---------------------------------------------------------------------
// Pestañas (solo en móvil, ver css/main.css): alternar entre el panel de
// "Secciones" y el de "Vista previa". A partir del breakpoint md la clase
// `panel-hidden-mobile` no tiene efecto y ambos paneles quedan visibles
// permanentemente, lado a lado.
// ---------------------------------------------------------------------
const tabSections = document.getElementById('tab-sections');
const tabOutput = document.getElementById('tab-output');
const panelOutput = document.getElementById('panel-output');
function showTab(tab) {
const showSections = tab === 'sections';
panelTop.classList.toggle('panel-hidden-mobile', !showSections);
panelOutput.classList.toggle('panel-hidden-mobile', showSections);
tabSections.classList.toggle('tab-active', showSections);
tabOutput.classList.toggle('tab-active', !showSections);
}
tabSections.addEventListener('click', () => showTab('sections'));
tabOutput.addEventListener('click', () => showTab('output'));
// Exportar #panel-output a PDF: se usa el diálogo de impresión del propio
// navegador (con "Guardar como PDF") en vez de una librería externa, ya
// que las librerías tipo html2canvas no renderizan bien los colores oklch
// que usa Tailwind v4. La hoja de estilos de impresión (css/main.css)
// aísla #panel-output para que solo se exporte el resultado.
document.getElementById('export-pdf').addEventListener('click', () => {
// `panel-hidden-mobile` está restringida a "screen" en el CSS, así que
// nunca debería afectar a la impresión, pero por si algún navegador
// calcula el ancho de la vista de impresión igual al de pantalla en
// móvil, se cambia de pestaña antes de imprimir para curarse en salud.
showTab('output');
window.print();
});
// ---------------------------------------------------------------------
// Guardar / cargar las secciones (serializarlas a datos planos)
// ---------------------------------------------------------------------
// Convierte una sección en un objeto plano según su tipo. Si `section` es
// un "Grupo", se serializa recursivamente cada una de sus propias
// secciones dentro de `children`. El chequeo de grupo va primero por la
// misma razón que en renderOutputSection(): evita que un `querySelector`
// más genérico encuentre por error un control anidado de una sección hija.
function serializeSection(section) {
const groupContainer = section.querySelector('.group-container');
if (groupContainer) {
return { type: 'group', children: Array.from(groupContainer.children).map(serializeSection).filter(Boolean) };
}
const titleInput = section.querySelector('.title-input');
if (titleInput) return { type: 'title', text: titleInput.value };
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) return { type: 'subtitle', text: subtitleInput.value };
const textarea = section.querySelector('textarea');
if (textarea) return { type: 'text', text: textarea.value };
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {
const items = Array.from(dndCanvas.children).map(group => {
const input = group.querySelector('input');
const label = group.querySelector('li');
if (!input || !label) return null;
return { label: label.textContent.trim(), count: Number(input.value) };
}).filter(Boolean);
return { type: 'dnd', items };
}
const img = section.querySelector('img');
if (img) {
const dataUrl = !img.classList.contains('hidden') ? img.src : '';
return { type: 'image', dataUrl };
}
return null;
}
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
// (uno por sección, anidados si son grupos), listo para guardarse como
// JSON en cualquier sitio (localStorage, o enviado a un backend Django).
function serializeSections() {
return Array.from(sectionsContainer.children).map(serializeSection).filter(Boolean);
}
// Añade a `container` la sección que describe `sectionData` (el mismo
// formato que genera serializeSection()), delegando en la función
// addXxxSection que corresponda según su tipo. La sección siempre se
// añade al final de `container`; quien necesite colocarla en otra
// posición (ver duplicateSection()) debe moverla después.
function addSectionFromData(sectionData, container) {
if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text, container);
if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text, container);
if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text, container);
if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl, container);
if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items, container);
if (sectionData.type === 'group') addGroupSection(addGroupBtn.dataset, sectionData.children, container);
}
// Reconstruye `container` (#panel-top-sections por defecto, o el de un
// grupo) a partir del array que genera serializeSections(), sustituyendo
// las secciones que ya tuviera.
function buildSectionsFrom(sectionsData, container = sectionsContainer) {
container.innerHTML = '';
sectionsData.forEach(sectionData => addSectionFromData(sectionData, container));
}
// Duplica `section`: la serializa (con serializeSection(), incluido su
// contenido si es un "Grupo") y reconstruye una copia a partir de esos
// datos, insertándola justo debajo de la original dentro del mismo
// contenedor (nivel superior, o el de un grupo). addSectionFromData()
// siempre añade al final del contenedor, así que la copia se reubica
// después con insertBefore.
function duplicateSection(section) {
const data = serializeSection(section);
if (!data) return;
const container = section.parentElement;
addSectionFromData(data, container);
const clone = container.lastElementChild;
container.insertBefore(clone, section.nextSibling);
}
// Ejemplo de uso: guardar/cargar todas las secciones en localStorage.
// Para integrarlo con Django, bastaría con sustituir estos dos listeners
// por una llamada a la API (POST del JSON de serializeSections() / GET
// para pasarle el resultado a buildSectionsFrom()).
const SECTIONS_STORAGE_KEY = 'sections-data';
document.getElementById('save-pattern').addEventListener('click', () => {
localStorage.setItem(SECTIONS_STORAGE_KEY, JSON.stringify(serializeSections()));
});
document.getElementById('load-pattern').addEventListener('click', () => {
const raw = localStorage.getItem(SECTIONS_STORAGE_KEY);
if (!raw) return;
buildSectionsFrom(JSON.parse(raw));
});
renderOutput(); // Pinta el mensaje de "sin secciones" nada más cargar la página.
});