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". const languageSelect = document.getElementById('language-select'); // Idioma del contenido (no de la interfaz): controla qué versión de los textos de título/subtítulo/texto se muestra y edita. const panelOutput = document.getElementById('panel-output'); // Controles de "Personalización de página": configuración única para todo // el documento (no una sección más), ver el bloque más abajo. const pageTitleInput = document.getElementById('page-title-input'); const pageAuthorInput = document.getElementById('page-author-input'); const pageBgColorInput = document.getElementById('page-bg-color'); const pageFontSelect = document.getElementById('page-font-select'); const pageSizeSelect = document.getElementById('page-size-select'); const pageOrientationSelect = document.getElementById('page-orientation-select'); const pageSizeStyle = document.getElementById('page-size-style'); // Idioma actualmente seleccionado para el contenido. La estructura de // secciones (cuáles hay, su orden, su tipo) es la misma para todos los // idiomas; lo único que cambia es qué texto se muestra en cada campo de // título/subtítulo/texto libre. let currentLanguage = languageSelect.value; // --------------------------------------------------------------------- // Multi-idioma del contenido: título, subtítulo y texto libre guardan un // texto por idioma en vez de uno solo, para poder editarlos y asociarlos // al idioma seleccionado sin perder lo ya escrito en los demás. // --------------------------------------------------------------------- // Cada input/textarea traducible guarda su propio mapa { idioma: texto } // serializado en el propio elemento (data-translations), así viaja solo // con guardar/cargar la sección (ver serializeSection/addTitleSection...), // sin necesidad de un almacén aparte que haya que mantener sincronizado. function getTranslations(el) { try { return JSON.parse(el.dataset.translations || '{}'); } catch { return {}; } } // Actualiza, dentro del mapa ya guardado en el elemento, el texto del // idioma indicado (por defecto el actual), sin tocar el resto de idiomas. function setTranslation(el, text, lang = currentLanguage) { const translations = getTranslations(el); translations[lang] = text; el.dataset.translations = JSON.stringify(translations); } // --------------------------------------------------------------------- // 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 textInput = section.querySelector('.text-input'); if (textInput) { appendOutputLine(target, 'p', textInput.value.trim(), ''); return; } // Nota/consejo: se destaca con un "alert" de daisyUI (para la forma: // padding, bordes redondeados...) pero con los colores de texto y fondo // que haya elegido el usuario, en vez de los del tema, para que se // distinga a simple vista de un texto libre. const noteInput = section.querySelector('.note-input'); if (noteInput) { const text = noteInput.value.trim(); if (text) { const note = document.createElement('div'); note.className = 'alert text-sm my-2'; const textColorInput = section.querySelector('.note-text-color'); const bgColorInput = section.querySelector('.note-bg-color'); if (textColorInput) note.style.color = textColorInput.value; if (bgColorInput) note.style.backgroundColor = bgColorInput.value; note.textContent = text; target.appendChild(note); } return; } // Materiales: una línea por elemento de la lista, en vez de un único párrafo. const materialsList = section.querySelector('.materials-list'); if (materialsList) { const items = Array.from(materialsList.querySelectorAll('.material-input')) .map(input => input.value.trim()) .filter(Boolean); if (items.length) { const ul = document.createElement('ul'); ul.className = 'list-disc list-inside my-1'; items.forEach(text => { const li = document.createElement('li'); li.textContent = text; ul.appendChild(li); }); target.appendChild(ul); } 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 = ''; // Título y autor del patrón (ver "Personalización de página"): son // configuración de todo el documento, no una sección más, así que se // pintan siempre delante, haya o no secciones añadidas. appendOutputLine(outputText, 'h1', pageTitleInput.value.trim(), 'text-2xl font-bold mb-1'); appendOutputLine(outputText, 'p', pageAuthorInput.value.trim(), 'text-sm text-base-content/70 mb-2'); // 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". // Si el campo es traducible (título/subtítulo/texto), cada pulsación de // tecla también se guarda en su mapa de traducciones para el idioma // actual, así no hace falta un paso aparte de "guardar" al cambiar de // idioma o al serializar la sección. const TRANSLATABLE_FIELDS_SELECTOR = '.title-input, .subtitle-input, .material-input, textarea'; // textarea cubre tanto "Texto" (.text-input) como "Nota" (.note-input). sectionsContainer.addEventListener('input', (e) => { if (e.target.matches(TRANSLATABLE_FIELDS_SELECTOR)) { setTranslation(e.target, e.target.value); } renderOutput(); }); sectionsContainer.addEventListener('change', renderOutput); new MutationObserver(renderOutput).observe(sectionsContainer, { childList: true, subtree: true }); // Al cambiar el idioma del contenido, se sustituye en cada campo // traducible (a cualquier nivel de anidamiento) el texto mostrado por el // que tenga guardado para el nuevo idioma (vacío si aún no se ha escrito // nada en ese idioma), y se repinta la vista previa. languageSelect.addEventListener('change', () => { currentLanguage = languageSelect.value; sectionsContainer.querySelectorAll(TRANSLATABLE_FIELDS_SELECTOR).forEach(el => { el.value = getTranslations(el)[currentLanguage] || ''; }); renderOutput(); }); // --------------------------------------------------------------------- // 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). `translations`, si se pasa, es el mapa { idioma: // texto } completo (se usa al restaurar un patrón guardado o al // duplicar una sección); se muestra el que corresponda al idioma actual. // `container` es dónde se añade la sección: #panel-top-sections por // defecto, o el de un grupo. function addTitleSection(data, translations = {}, 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.dataset.translations = JSON.stringify(translations); input.value = translations[currentLanguage] || ''; 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, translations = {}, 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.dataset.translations = JSON.stringify(translations); input.value = translations[currentLanguage] || ''; body.appendChild(input); container.appendChild(section); } // Sección de texto libre. Se marca con la clase 'text-input' (además de // 'textarea') para poder distinguirla de la de "Nota" en // serializeSection()/renderOutputSection(): ambas son un