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; // 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 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. }); // 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; } // --------------------------------------------------------------------- // 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. 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'); dropCanvas.classList.add('drop-target-active'); }); clone.addEventListener('dragend', () => { group.classList.remove('dragging'); dropCanvas.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'); dropCanvas.classList.add('drop-target-active'); const onTouchMove = (moveEvent) => { moveEvent.preventDefault(); const point = getTouchPoint(moveEvent); const afterElement = getItemAfterPoint(dropCanvas, point.x, point.y); if (afterElement == null) { dropCanvas.appendChild(draggedItem); } else if (afterElement !== draggedItem) { dropCanvas.insertBefore(draggedItem, afterElement); } }; const onTouchEnd = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); group.classList.remove('dragging'); dropCanvas.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). function setupDropZone(dropCanvas, onChange) { dropCanvas.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); if (afterElement == null) { dropCanvas.appendChild(draggedItem); } else if (afterElement !== draggedItem) { dropCanvas.insertBefore(draggedItem, afterElement); } return; } e.dataTransfer.dropEffect = 'copy'; }); dropCanvas.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 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'); sectionsContainer.classList.add('drop-target-active'); }); dragHandle.addEventListener('dragend', () => { section.classList.remove('dragging'); sectionsContainer.classList.remove('drop-target-active'); draggedSection = null; }); // Equivalente táctil del reordenado por dragover/drop de más arriba: // mientras el dedo se mueve, se reutiliza getSectionAfterY() para ir // desplazando la sección en vivo dentro de #panel-top-sections. dragHandle.addEventListener('touchstart', (e) => { e.preventDefault(); draggedSection = section; section.classList.add('dragging'); sectionsContainer.classList.add('drop-target-active'); const onTouchMove = (moveEvent) => { moveEvent.preventDefault(); const { y } = getTouchPoint(moveEvent); const afterElement = getSectionAfterY(y); if (afterElement == null) { sectionsContainer.appendChild(draggedSection); } else if (afterElement !== draggedSection) { sectionsContainer.insertBefore(draggedSection, afterElement); } }; const onTouchEnd = () => { document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); section.classList.remove('dragging'); sectionsContainer.classList.remove('drop-target-active'); draggedSection = 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 = '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 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); 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)); // --------------------------------------------------------------------- // 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 panelTop = document.getElementById('panel-top'); 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) // --------------------------------------------------------------------- // 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. });