document.addEventListener('DOMContentLoaded', () => {
// Referencias a los elementos fijos del documento.
const outputText = document.getElementById('panel-output-text'); // Donde se pinta el resultado final.
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 el asa "⠿" de cada una)
// ---------------------------------------------------------------------
// Sección que se está arrastrando actualmente para reordenar. Se usa como
// "memoria" compartida entre los listeners en lugar de dataTransfer, así
// no interfiere con el drag & drop de los elementos arrastrables.
let draggedSection = null;
// Dado 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).
function getSectionAfterY(y) {
const sections = Array.from(sectionsContainer.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;
}
sectionsContainer.addEventListener('dragover', (e) => {
if (!draggedSection) return; // No es un arrastre de reordenación, ignorar.
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(e.clientY);
if (afterElement == null) {
sectionsContainer.appendChild(draggedSection);
} else if (afterElement !== draggedSection) {
sectionsContainer.insertBefore(draggedSection, afterElement);
}
});
sectionsContainer.addEventListener('drop', (e) => {
if (!draggedSection) return;
e.preventDefault(); // El reordenado ya se hizo en el dragover; aquí solo evitamos el comportamiento por defecto del navegador.
});
// ---------------------------------------------------------------------
// 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.
function makeDraggable(item) {
item.setAttribute('draggable', 'true');
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/html', item.outerHTML);
});
}
// 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('draggable');
clone.removeAttribute('id');
clone.classList.add('inline-block');
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());
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).
function setupDropZone(dropCanvas, onChange) {
dropCanvas.addEventListener('dragover', (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
dropCanvas.addEventListener('drop', (e) => {
e.preventDefault();
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 al resultado (si no está vacía).
function appendOutputLine(tag, text, className) {
if (!text) return;
const el = document.createElement(tag);
el.className = className;
el.textContent = text;
outputText.appendChild(el);
}
// Recorre todas las secciones (en su orden actual) y reconstruye
// #panel-output desde cero: cada sección aporta una línea/elemento según
// su tipo (título, texto, drag&drop o imagen).
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('p', outputText.dataset.emptyMessage, 'text-base-content/50 italic');
return;
}
Array.from(sectionsContainer.children).forEach(section => {
const titleInput = section.querySelector('.title-input');
if (titleInput) {
appendOutputLine('h3', titleInput.value.trim(), 'text-lg font-bold mt-2 mb-1');
return;
}
const subtitleInput = section.querySelector('.subtitle-input');
if (subtitleInput) {
appendOutputLine('h4', subtitleInput.value.trim(), 'text-base font-semibold mt-1 mb-1');
return;
}
const textarea = section.querySelector('textarea');
if (textarea) {
appendOutputLine('p', textarea.value.trim(), '');
return;
}
const dndCanvas = section.querySelector('.dnd-canvas');
if (dndCanvas) {
appendOutputLine('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';
outputText.appendChild(img);
}
}
});
}
// 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).
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)
// ---------------------------------------------------------------------
// 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 }: `section` se añade a #panel-top-sections,
// `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';
const header = document.createElement('div');
header.className = 'flex items-center gap-2';
const dragHandle = document.createElement('span');
dragHandle.className = 'cursor-move select-none text-base-content/50 px-1';
dragHandle.textContent = '⠿';
dragHandle.title = sectionsContainer.dataset.dragTitle;
dragHandle.setAttribute('draggable', 'true');
dragHandle.addEventListener('dragstart', (e) => {
draggedSection = section;
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', 'opacity-50');
});
dragHandle.addEventListener('dragend', () => {
section.classList.remove('dragging', 'opacity-50');
draggedSection = null;
});
// 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 = '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';
badge.textContent = typeLabel;
const spacer = document.createElement('div');
spacer.className = 'flex-1';
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(removeBtn);
const body = document.createElement('div');
body.className = '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).
function addTitleSection(data, text = '') {
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);
sectionsContainer.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 = '') {
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);
sectionsContainer.appendChild(section);
}
// Sección de texto libre.
function addTextSection(data, text = '') {
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);
sectionsContainer.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 = '') {
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);
sectionsContainer.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 = []) {
const { section, body } = createSectionShell(data.typeLabel);
const row = document.createElement('div');
row.className = 'flex 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-48 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);
sectionsContainer.appendChild(section);
setupDropZone(dndCanvas);
items.forEach(item => addDndItem(dndCanvas, item.label, item.count));
}
// 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');
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));
// 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', () => {
window.print();
});
// ---------------------------------------------------------------------
// Guardar / cargar las secciones (serializarlas a datos planos)
// ---------------------------------------------------------------------
// Recorre #panel-top-sections y lo convierte en un array de objetos planos
// (uno por sección), listo para guardarse como JSON en cualquier sitio
// (localStorage, o enviado a un backend Django).
function serializeSections() {
return Array.from(sectionsContainer.children).map(section => {
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;
}).filter(Boolean);
}
// Reconstruye #panel-top-sections a partir del array que genera
// serializeSections(), sustituyendo las secciones actuales.
function buildSectionsFrom(sectionsData) {
sectionsContainer.innerHTML = '';
sectionsData.forEach(sectionData => {
if (sectionData.type === 'title') addTitleSection(addTitleBtn.dataset, sectionData.text);
if (sectionData.type === 'subtitle') addSubtitleSection(addSubtitleBtn.dataset, sectionData.text);
if (sectionData.type === 'text') addTextSection(addTextBtn.dataset, sectionData.text);
if (sectionData.type === 'image') addImageSection(addImageBtn.dataset, sectionData.dataUrl);
if (sectionData.type === 'dnd') addDndSection(addDndBtn.dataset, sectionData.items);
});
}
// 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.
});